arthexis 0.1.22__py3-none-any.whl → 0.1.24__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.22.dist-info → arthexis-0.1.24.dist-info}/METADATA +6 -5
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/RECORD +26 -26
- config/settings.py +4 -0
- core/admin.py +200 -16
- core/models.py +878 -118
- core/release.py +0 -5
- core/tasks.py +25 -0
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +153 -132
- nodes/models.py +9 -1
- nodes/tests.py +106 -81
- nodes/urls.py +6 -0
- nodes/views.py +620 -48
- ocpp/admin.py +543 -166
- ocpp/models.py +57 -2
- ocpp/tasks.py +336 -1
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +117 -11
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/top_level.txt +0 -0
ocpp/models.py
CHANGED
|
@@ -15,6 +15,7 @@ from nodes.models import Node
|
|
|
15
15
|
|
|
16
16
|
from core.models import (
|
|
17
17
|
EnergyAccount,
|
|
18
|
+
EnergyTariff,
|
|
18
19
|
Reference,
|
|
19
20
|
RFID as CoreRFID,
|
|
20
21
|
ElectricVehicle as CoreElectricVehicle,
|
|
@@ -35,6 +36,22 @@ class Location(Entity):
|
|
|
35
36
|
longitude = models.DecimalField(
|
|
36
37
|
max_digits=9, decimal_places=6, null=True, blank=True
|
|
37
38
|
)
|
|
39
|
+
zone = models.CharField(
|
|
40
|
+
max_length=3,
|
|
41
|
+
choices=EnergyTariff.Zone.choices,
|
|
42
|
+
blank=True,
|
|
43
|
+
null=True,
|
|
44
|
+
help_text=_("CFE climate zone used to select matching energy tariffs."),
|
|
45
|
+
)
|
|
46
|
+
contract_type = models.CharField(
|
|
47
|
+
max_length=16,
|
|
48
|
+
choices=EnergyTariff.ContractType.choices,
|
|
49
|
+
blank=True,
|
|
50
|
+
null=True,
|
|
51
|
+
help_text=_(
|
|
52
|
+
"CFE service contract type required to match energy tariff pricing."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
38
55
|
|
|
39
56
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
40
57
|
return self.name
|
|
@@ -215,6 +232,13 @@ class Charger(Entity):
|
|
|
215
232
|
"Latest GetConfiguration response received from this charge point."
|
|
216
233
|
),
|
|
217
234
|
)
|
|
235
|
+
node_origin = models.ForeignKey(
|
|
236
|
+
"nodes.Node",
|
|
237
|
+
on_delete=models.SET_NULL,
|
|
238
|
+
null=True,
|
|
239
|
+
blank=True,
|
|
240
|
+
related_name="origin_chargers",
|
|
241
|
+
)
|
|
218
242
|
manager_node = models.ForeignKey(
|
|
219
243
|
"nodes.Node",
|
|
220
244
|
on_delete=models.SET_NULL,
|
|
@@ -222,6 +246,9 @@ class Charger(Entity):
|
|
|
222
246
|
blank=True,
|
|
223
247
|
related_name="managed_chargers",
|
|
224
248
|
)
|
|
249
|
+
allow_remote = models.BooleanField(default=False)
|
|
250
|
+
export_transactions = models.BooleanField(default=False)
|
|
251
|
+
last_online_at = models.DateTimeField(null=True, blank=True)
|
|
225
252
|
owner_users = models.ManyToManyField(
|
|
226
253
|
settings.AUTH_USER_MODEL,
|
|
227
254
|
blank=True,
|
|
@@ -276,6 +303,24 @@ class Charger(Entity):
|
|
|
276
303
|
user_group_ids = user.groups.values_list("pk", flat=True)
|
|
277
304
|
return self.owner_groups.filter(pk__in=user_group_ids).exists()
|
|
278
305
|
|
|
306
|
+
@property
|
|
307
|
+
def is_local(self) -> bool:
|
|
308
|
+
"""Return ``True`` when this charger originates from the local node."""
|
|
309
|
+
|
|
310
|
+
local = Node.get_local()
|
|
311
|
+
if not local:
|
|
312
|
+
return False
|
|
313
|
+
if self.node_origin_id is None:
|
|
314
|
+
return True
|
|
315
|
+
return self.node_origin_id == local.pk
|
|
316
|
+
|
|
317
|
+
def save(self, *args, **kwargs):
|
|
318
|
+
if self.node_origin_id is None:
|
|
319
|
+
local = Node.get_local()
|
|
320
|
+
if local:
|
|
321
|
+
self.node_origin = local
|
|
322
|
+
super().save(*args, **kwargs)
|
|
323
|
+
|
|
279
324
|
class Meta:
|
|
280
325
|
verbose_name = _("Charge Point")
|
|
281
326
|
verbose_name_plural = _("Charge Points")
|
|
@@ -481,11 +526,17 @@ class Charger(Entity):
|
|
|
481
526
|
ref_value = self._full_url()
|
|
482
527
|
if url_targets_local_loopback(ref_value):
|
|
483
528
|
return
|
|
484
|
-
if not self.reference
|
|
529
|
+
if not self.reference:
|
|
485
530
|
self.reference = Reference.objects.create(
|
|
486
531
|
value=ref_value, alt_text=self.charger_id
|
|
487
532
|
)
|
|
488
533
|
super().save(update_fields=["reference"])
|
|
534
|
+
elif self.reference.value != ref_value:
|
|
535
|
+
Reference.objects.filter(pk=self.reference_id).update(
|
|
536
|
+
value=ref_value, alt_text=self.charger_id
|
|
537
|
+
)
|
|
538
|
+
self.reference.value = ref_value
|
|
539
|
+
self.reference.alt_text = self.charger_id
|
|
489
540
|
|
|
490
541
|
def refresh_manager_node(self, node: Node | None = None) -> Node | None:
|
|
491
542
|
"""Ensure ``manager_node`` matches the provided or local node."""
|
|
@@ -783,7 +834,11 @@ class Transaction(Entity):
|
|
|
783
834
|
def vehicle_identifier(self) -> str:
|
|
784
835
|
"""Return the preferred vehicle identifier for this transaction."""
|
|
785
836
|
|
|
786
|
-
|
|
837
|
+
vid = (self.vid or "").strip()
|
|
838
|
+
if vid:
|
|
839
|
+
return vid
|
|
840
|
+
|
|
841
|
+
return (self.vin or "").strip()
|
|
787
842
|
|
|
788
843
|
@property
|
|
789
844
|
def vehicle_identifier_source(self) -> str:
|
ocpp/tasks.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import uuid
|
|
4
5
|
from datetime import date, datetime, time, timedelta
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
from asgiref.sync import async_to_sync
|
|
@@ -9,17 +11,259 @@ from celery import shared_task
|
|
|
9
11
|
from django.conf import settings
|
|
10
12
|
from django.contrib.auth import get_user_model
|
|
11
13
|
from django.utils import timezone
|
|
14
|
+
from django.utils.dateparse import parse_datetime
|
|
12
15
|
from django.db.models import Q
|
|
16
|
+
import requests
|
|
17
|
+
from requests import RequestException
|
|
18
|
+
from cryptography.hazmat.primitives import hashes
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
13
20
|
|
|
14
21
|
from core import mailer
|
|
15
22
|
from nodes.models import Node
|
|
16
23
|
|
|
17
24
|
from . import store
|
|
18
|
-
from .models import Charger, MeterValue, Transaction
|
|
25
|
+
from .models import Charger, Location, MeterValue, Transaction
|
|
19
26
|
|
|
20
27
|
logger = logging.getLogger(__name__)
|
|
21
28
|
|
|
22
29
|
|
|
30
|
+
def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
31
|
+
if not private_key:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
signature = private_key.sign(
|
|
35
|
+
payload_json.encode(),
|
|
36
|
+
padding.PKCS1v15(),
|
|
37
|
+
hashes.SHA256(),
|
|
38
|
+
)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
return base64.b64encode(signature).decode()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_remote_datetime(value):
|
|
45
|
+
if not value:
|
|
46
|
+
return None
|
|
47
|
+
dt = parse_datetime(value)
|
|
48
|
+
if dt is None:
|
|
49
|
+
return None
|
|
50
|
+
if timezone.is_naive(dt):
|
|
51
|
+
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
|
52
|
+
return dt
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _to_decimal(value):
|
|
56
|
+
if value in (None, ""):
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return Decimal(str(value))
|
|
60
|
+
except (InvalidOperation, TypeError, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _apply_remote_charger_payload(node, payload: dict) -> Charger | None:
|
|
65
|
+
serial = Charger.normalize_serial(payload.get("charger_id"))
|
|
66
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
connector = payload.get("connector_id")
|
|
70
|
+
if connector in (None, ""):
|
|
71
|
+
connector_value = None
|
|
72
|
+
elif isinstance(connector, int):
|
|
73
|
+
connector_value = connector
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
connector_value = int(str(connector))
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
connector_value = None
|
|
79
|
+
|
|
80
|
+
charger = Charger.objects.filter(
|
|
81
|
+
charger_id=serial, connector_id=connector_value
|
|
82
|
+
).first()
|
|
83
|
+
if not charger:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
location_payload = payload.get("location")
|
|
87
|
+
location_obj = None
|
|
88
|
+
if isinstance(location_payload, dict):
|
|
89
|
+
name = location_payload.get("name")
|
|
90
|
+
if name:
|
|
91
|
+
location_obj, _ = Location.objects.get_or_create(name=name)
|
|
92
|
+
for field in ("latitude", "longitude", "zone", "contract_type"):
|
|
93
|
+
setattr(location_obj, field, location_payload.get(field))
|
|
94
|
+
location_obj.save()
|
|
95
|
+
|
|
96
|
+
datetime_fields = [
|
|
97
|
+
"firmware_timestamp",
|
|
98
|
+
"last_heartbeat",
|
|
99
|
+
"availability_state_updated_at",
|
|
100
|
+
"availability_requested_at",
|
|
101
|
+
"availability_request_status_at",
|
|
102
|
+
"diagnostics_timestamp",
|
|
103
|
+
"last_status_timestamp",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
updates: dict[str, object] = {
|
|
107
|
+
"node_origin": node,
|
|
108
|
+
"allow_remote": bool(payload.get("allow_remote", False)),
|
|
109
|
+
"export_transactions": bool(payload.get("export_transactions", False)),
|
|
110
|
+
"last_online_at": timezone.now(),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
simple_fields = [
|
|
114
|
+
"display_name",
|
|
115
|
+
"language",
|
|
116
|
+
"public_display",
|
|
117
|
+
"require_rfid",
|
|
118
|
+
"firmware_status",
|
|
119
|
+
"firmware_status_info",
|
|
120
|
+
"last_status",
|
|
121
|
+
"last_error_code",
|
|
122
|
+
"last_status_vendor_info",
|
|
123
|
+
"availability_state",
|
|
124
|
+
"availability_requested_state",
|
|
125
|
+
"availability_request_status",
|
|
126
|
+
"availability_request_details",
|
|
127
|
+
"temperature",
|
|
128
|
+
"temperature_unit",
|
|
129
|
+
"diagnostics_status",
|
|
130
|
+
"diagnostics_location",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
for field in simple_fields:
|
|
134
|
+
updates[field] = payload.get(field)
|
|
135
|
+
|
|
136
|
+
if location_obj is not None:
|
|
137
|
+
updates["location"] = location_obj
|
|
138
|
+
|
|
139
|
+
for field in datetime_fields:
|
|
140
|
+
updates[field] = _parse_remote_datetime(payload.get(field))
|
|
141
|
+
|
|
142
|
+
updates["last_meter_values"] = payload.get("last_meter_values") or {}
|
|
143
|
+
|
|
144
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
145
|
+
charger.refresh_from_db()
|
|
146
|
+
return charger
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sync_transactions_payload(payload: dict) -> int:
|
|
150
|
+
if not isinstance(payload, dict):
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
chargers_map: dict[tuple[str, int | None], Charger] = {}
|
|
154
|
+
for entry in payload.get("chargers", []):
|
|
155
|
+
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
156
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
157
|
+
continue
|
|
158
|
+
connector = entry.get("connector_id")
|
|
159
|
+
if connector in (None, ""):
|
|
160
|
+
connector_value = None
|
|
161
|
+
elif isinstance(connector, int):
|
|
162
|
+
connector_value = connector
|
|
163
|
+
else:
|
|
164
|
+
try:
|
|
165
|
+
connector_value = int(str(connector))
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
connector_value = None
|
|
168
|
+
charger = Charger.objects.filter(
|
|
169
|
+
charger_id=serial, connector_id=connector_value
|
|
170
|
+
).first()
|
|
171
|
+
if charger:
|
|
172
|
+
chargers_map[(serial, connector_value)] = charger
|
|
173
|
+
|
|
174
|
+
imported = 0
|
|
175
|
+
for tx in payload.get("transactions", []):
|
|
176
|
+
if not isinstance(tx, dict):
|
|
177
|
+
continue
|
|
178
|
+
serial = Charger.normalize_serial(tx.get("charger"))
|
|
179
|
+
if not serial:
|
|
180
|
+
continue
|
|
181
|
+
connector = tx.get("connector_id")
|
|
182
|
+
if connector in (None, ""):
|
|
183
|
+
connector_value = None
|
|
184
|
+
elif isinstance(connector, int):
|
|
185
|
+
connector_value = connector
|
|
186
|
+
else:
|
|
187
|
+
try:
|
|
188
|
+
connector_value = int(str(connector))
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
connector_value = None
|
|
191
|
+
|
|
192
|
+
charger = chargers_map.get((serial, connector_value))
|
|
193
|
+
if charger is None:
|
|
194
|
+
charger = chargers_map.get((serial, None))
|
|
195
|
+
if charger is None:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
start_time = _parse_remote_datetime(tx.get("start_time"))
|
|
199
|
+
if start_time is None:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
defaults = {
|
|
203
|
+
"connector_id": connector_value,
|
|
204
|
+
"account_id": tx.get("account"),
|
|
205
|
+
"rfid": tx.get("rfid", ""),
|
|
206
|
+
"vid": tx.get("vid", ""),
|
|
207
|
+
"vin": tx.get("vin", ""),
|
|
208
|
+
"meter_start": tx.get("meter_start"),
|
|
209
|
+
"meter_stop": tx.get("meter_stop"),
|
|
210
|
+
"voltage_start": _to_decimal(tx.get("voltage_start")),
|
|
211
|
+
"voltage_stop": _to_decimal(tx.get("voltage_stop")),
|
|
212
|
+
"current_import_start": _to_decimal(tx.get("current_import_start")),
|
|
213
|
+
"current_import_stop": _to_decimal(tx.get("current_import_stop")),
|
|
214
|
+
"current_offered_start": _to_decimal(tx.get("current_offered_start")),
|
|
215
|
+
"current_offered_stop": _to_decimal(tx.get("current_offered_stop")),
|
|
216
|
+
"temperature_start": _to_decimal(tx.get("temperature_start")),
|
|
217
|
+
"temperature_stop": _to_decimal(tx.get("temperature_stop")),
|
|
218
|
+
"soc_start": _to_decimal(tx.get("soc_start")),
|
|
219
|
+
"soc_stop": _to_decimal(tx.get("soc_stop")),
|
|
220
|
+
"stop_time": _parse_remote_datetime(tx.get("stop_time")),
|
|
221
|
+
"received_start_time": _parse_remote_datetime(
|
|
222
|
+
tx.get("received_start_time")
|
|
223
|
+
),
|
|
224
|
+
"received_stop_time": _parse_remote_datetime(
|
|
225
|
+
tx.get("received_stop_time")
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
transaction, _ = Transaction.objects.update_or_create(
|
|
230
|
+
charger=charger,
|
|
231
|
+
start_time=start_time,
|
|
232
|
+
defaults=defaults,
|
|
233
|
+
)
|
|
234
|
+
transaction.meter_values.all().delete()
|
|
235
|
+
for mv in tx.get("meter_values", []):
|
|
236
|
+
if not isinstance(mv, dict):
|
|
237
|
+
continue
|
|
238
|
+
timestamp = _parse_remote_datetime(mv.get("timestamp"))
|
|
239
|
+
if timestamp is None:
|
|
240
|
+
continue
|
|
241
|
+
connector_mv = mv.get("connector_id")
|
|
242
|
+
if connector_mv in (None, ""):
|
|
243
|
+
connector_mv = None
|
|
244
|
+
elif isinstance(connector_mv, str):
|
|
245
|
+
try:
|
|
246
|
+
connector_mv = int(connector_mv)
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
connector_mv = None
|
|
249
|
+
MeterValue.objects.create(
|
|
250
|
+
charger=charger,
|
|
251
|
+
transaction=transaction,
|
|
252
|
+
connector_id=connector_mv,
|
|
253
|
+
timestamp=timestamp,
|
|
254
|
+
context=mv.get("context", ""),
|
|
255
|
+
energy=_to_decimal(mv.get("energy")),
|
|
256
|
+
voltage=_to_decimal(mv.get("voltage")),
|
|
257
|
+
current_import=_to_decimal(mv.get("current_import")),
|
|
258
|
+
current_offered=_to_decimal(mv.get("current_offered")),
|
|
259
|
+
temperature=_to_decimal(mv.get("temperature")),
|
|
260
|
+
soc=_to_decimal(mv.get("soc")),
|
|
261
|
+
)
|
|
262
|
+
imported += 1
|
|
263
|
+
|
|
264
|
+
return imported
|
|
265
|
+
|
|
266
|
+
|
|
23
267
|
@shared_task
|
|
24
268
|
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
269
|
"""Request the latest configuration from a connected charge point."""
|
|
@@ -135,6 +379,97 @@ def purge_meter_values() -> int:
|
|
|
135
379
|
purge_meter_readings = purge_meter_values
|
|
136
380
|
|
|
137
381
|
|
|
382
|
+
@shared_task
|
|
383
|
+
def sync_remote_chargers() -> int:
|
|
384
|
+
"""Synchronize remote charger metadata and transactions."""
|
|
385
|
+
|
|
386
|
+
local = Node.get_local()
|
|
387
|
+
if not local:
|
|
388
|
+
logger.debug("Remote sync skipped: local node not registered")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
private_key = local.get_private_key()
|
|
392
|
+
if private_key is None:
|
|
393
|
+
logger.warning("Remote sync skipped: missing local node private key")
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
chargers = (
|
|
397
|
+
Charger.objects.filter(export_transactions=True)
|
|
398
|
+
.exclude(node_origin__isnull=True)
|
|
399
|
+
.select_related("node_origin")
|
|
400
|
+
)
|
|
401
|
+
if local.pk:
|
|
402
|
+
chargers = chargers.exclude(node_origin=local)
|
|
403
|
+
|
|
404
|
+
grouped: dict[Node, list[Charger]] = {}
|
|
405
|
+
for charger in chargers:
|
|
406
|
+
origin = charger.node_origin
|
|
407
|
+
if not origin:
|
|
408
|
+
continue
|
|
409
|
+
grouped.setdefault(origin, []).append(charger)
|
|
410
|
+
|
|
411
|
+
if not grouped:
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
synced = 0
|
|
415
|
+
for node, node_chargers in grouped.items():
|
|
416
|
+
payload = {
|
|
417
|
+
"requester": str(local.uuid),
|
|
418
|
+
"include_transactions": True,
|
|
419
|
+
"chargers": [],
|
|
420
|
+
}
|
|
421
|
+
for charger in node_chargers:
|
|
422
|
+
payload["chargers"].append(
|
|
423
|
+
{
|
|
424
|
+
"charger_id": charger.charger_id,
|
|
425
|
+
"connector_id": charger.connector_id,
|
|
426
|
+
"since": charger.last_online_at.isoformat()
|
|
427
|
+
if charger.last_online_at
|
|
428
|
+
else None,
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
432
|
+
signature = _sign_payload(payload_json, private_key)
|
|
433
|
+
headers = {"Content-Type": "application/json"}
|
|
434
|
+
if signature:
|
|
435
|
+
headers["X-Signature"] = signature
|
|
436
|
+
|
|
437
|
+
url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
|
|
438
|
+
try:
|
|
439
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
440
|
+
except RequestException as exc:
|
|
441
|
+
logger.warning("Failed to sync chargers from %s: %s", node, exc)
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
if not response.ok:
|
|
445
|
+
logger.warning(
|
|
446
|
+
"Sync request to %s returned %s", node, response.status_code
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
data = response.json()
|
|
452
|
+
except ValueError:
|
|
453
|
+
logger.warning("Invalid JSON payload received from %s", node)
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
chargers_payload = data.get("chargers", [])
|
|
457
|
+
for entry in chargers_payload:
|
|
458
|
+
charger = _apply_remote_charger_payload(node, entry)
|
|
459
|
+
if charger:
|
|
460
|
+
synced += 1
|
|
461
|
+
|
|
462
|
+
transactions_payload = data.get("transactions")
|
|
463
|
+
if transactions_payload:
|
|
464
|
+
imported = _sync_transactions_payload(transactions_payload)
|
|
465
|
+
if imported:
|
|
466
|
+
logger.info(
|
|
467
|
+
"Imported %s transaction(s) from node %s", imported, node
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return synced
|
|
471
|
+
|
|
472
|
+
|
|
138
473
|
def _resolve_report_window() -> tuple[datetime, datetime, date]:
|
|
139
474
|
"""Return the start/end datetimes for today's reporting window."""
|
|
140
475
|
|
ocpp/tests.py
CHANGED
|
@@ -177,6 +177,36 @@ class DispatchActionTests(TestCase):
|
|
|
177
177
|
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
178
178
|
self.assertEqual(metadata.get("log_key"), log_key)
|
|
179
179
|
|
|
180
|
+
def test_reset_rejected_when_transaction_active(self):
|
|
181
|
+
charger = Charger.objects.create(charger_id="RESETBLOCK")
|
|
182
|
+
dummy = DummyWebSocket()
|
|
183
|
+
connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
|
|
184
|
+
self.addCleanup(lambda: store.connections.pop(connection_key, None))
|
|
185
|
+
tx_obj = Transaction.objects.create(
|
|
186
|
+
charger=charger,
|
|
187
|
+
connector_id=charger.connector_id,
|
|
188
|
+
start_time=timezone.now(),
|
|
189
|
+
)
|
|
190
|
+
tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
191
|
+
self.addCleanup(lambda: store.transactions.pop(tx_key, None))
|
|
192
|
+
|
|
193
|
+
request = self.factory.post(
|
|
194
|
+
"/chargers/RESETBLOCK/action/",
|
|
195
|
+
data=json.dumps({"action": "reset"}),
|
|
196
|
+
content_type="application/json",
|
|
197
|
+
)
|
|
198
|
+
request.user = SimpleNamespace(
|
|
199
|
+
is_authenticated=True,
|
|
200
|
+
is_superuser=True,
|
|
201
|
+
is_staff=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
response = dispatch_action(request, charger.charger_id)
|
|
205
|
+
self.assertEqual(response.status_code, 409)
|
|
206
|
+
payload = json.loads(response.content.decode("utf-8"))
|
|
207
|
+
self.assertIn("stop the session first", payload.get("detail", "").lower())
|
|
208
|
+
self.assertFalse(dummy.sent)
|
|
209
|
+
|
|
180
210
|
class ChargerFixtureTests(TestCase):
|
|
181
211
|
fixtures = [
|
|
182
212
|
p.name
|
|
@@ -218,6 +248,62 @@ class ChargerFixtureTests(TestCase):
|
|
|
218
248
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
219
249
|
|
|
220
250
|
|
|
251
|
+
class ChargerRefreshManagerNodeTests(TestCase):
|
|
252
|
+
@classmethod
|
|
253
|
+
def setUpTestData(cls):
|
|
254
|
+
local = Node.objects.create(
|
|
255
|
+
hostname="local-node",
|
|
256
|
+
address="127.0.0.1",
|
|
257
|
+
port=8000,
|
|
258
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
259
|
+
current_relation=Node.Relation.SELF,
|
|
260
|
+
)
|
|
261
|
+
Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
|
|
262
|
+
cls.local_node = Node.objects.get(pk=local.pk)
|
|
263
|
+
|
|
264
|
+
def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
|
|
265
|
+
charger = Charger(charger_id="UNSAVED")
|
|
266
|
+
|
|
267
|
+
with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
|
|
268
|
+
result = charger.refresh_manager_node()
|
|
269
|
+
|
|
270
|
+
self.assertEqual(result, self.local_node)
|
|
271
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
272
|
+
|
|
273
|
+
def test_refresh_manager_node_updates_persisted_charger(self):
|
|
274
|
+
remote = Node.objects.create(
|
|
275
|
+
hostname="remote-node",
|
|
276
|
+
address="10.0.0.1",
|
|
277
|
+
port=9000,
|
|
278
|
+
mac_address="11:22:33:44:55:66",
|
|
279
|
+
)
|
|
280
|
+
charger = Charger.objects.create(
|
|
281
|
+
charger_id="PERSISTED",
|
|
282
|
+
manager_node=remote,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
charger.refresh_manager_node(node=self.local_node)
|
|
286
|
+
|
|
287
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
288
|
+
charger.refresh_from_db()
|
|
289
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
290
|
+
|
|
291
|
+
def test_refresh_manager_node_handles_missing_local_node(self):
|
|
292
|
+
remote = Node.objects.create(
|
|
293
|
+
hostname="existing-manager",
|
|
294
|
+
address="10.0.0.2",
|
|
295
|
+
port=9001,
|
|
296
|
+
mac_address="22:33:44:55:66:77",
|
|
297
|
+
)
|
|
298
|
+
charger = Charger(charger_id="NOLOCAL", manager_node=remote)
|
|
299
|
+
|
|
300
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
301
|
+
result = charger.refresh_manager_node()
|
|
302
|
+
|
|
303
|
+
self.assertIsNone(result)
|
|
304
|
+
self.assertEqual(charger.manager_node, remote)
|
|
305
|
+
|
|
306
|
+
|
|
221
307
|
class ChargerUrlFallbackTests(TestCase):
|
|
222
308
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
223
309
|
def test_reference_created_when_site_missing(self):
|
|
@@ -2320,6 +2406,43 @@ class ChargerAdminTests(TestCase):
|
|
|
2320
2406
|
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2321
2407
|
store.clear_pending_calls(charger.charger_id)
|
|
2322
2408
|
|
|
2409
|
+
def test_reset_charger_action_skips_when_transaction_active(self):
|
|
2410
|
+
charger = Charger.objects.create(charger_id="RESETADMIN")
|
|
2411
|
+
|
|
2412
|
+
class DummyConnection:
|
|
2413
|
+
def __init__(self):
|
|
2414
|
+
self.sent: list[str] = []
|
|
2415
|
+
|
|
2416
|
+
async def send(self, message):
|
|
2417
|
+
self.sent.append(message)
|
|
2418
|
+
|
|
2419
|
+
ws = DummyConnection()
|
|
2420
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2421
|
+
tx_obj = Transaction.objects.create(
|
|
2422
|
+
charger=charger,
|
|
2423
|
+
connector_id=charger.connector_id,
|
|
2424
|
+
start_time=timezone.now(),
|
|
2425
|
+
)
|
|
2426
|
+
store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
2427
|
+
try:
|
|
2428
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2429
|
+
response = self.client.post(
|
|
2430
|
+
url,
|
|
2431
|
+
{
|
|
2432
|
+
"action": "reset_chargers",
|
|
2433
|
+
"index": 0,
|
|
2434
|
+
"select_across": 0,
|
|
2435
|
+
"_selected_action": [charger.pk],
|
|
2436
|
+
},
|
|
2437
|
+
follow=True,
|
|
2438
|
+
)
|
|
2439
|
+
self.assertEqual(response.status_code, 200)
|
|
2440
|
+
self.assertFalse(ws.sent)
|
|
2441
|
+
self.assertContains(response, "stop the session first")
|
|
2442
|
+
finally:
|
|
2443
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2444
|
+
store.pop_transaction(charger.charger_id, charger.connector_id)
|
|
2445
|
+
|
|
2323
2446
|
def test_admin_log_view_displays_entries(self):
|
|
2324
2447
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2325
2448
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
ocpp/views.py
CHANGED
|
@@ -1609,6 +1609,15 @@ def charger_session_search(request, cid, connector=None):
|
|
|
1609
1609
|
transactions = qs.order_by("-start_time")
|
|
1610
1610
|
except ValueError:
|
|
1611
1611
|
transactions = []
|
|
1612
|
+
if transactions is not None:
|
|
1613
|
+
transactions = list(transactions)
|
|
1614
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1615
|
+
for tx in transactions:
|
|
1616
|
+
details = _transaction_rfid_details(tx, cache=rfid_cache)
|
|
1617
|
+
label_value = None
|
|
1618
|
+
if details:
|
|
1619
|
+
label_value = str(details.get("label") or "").strip() or None
|
|
1620
|
+
tx.rfid_label = label_value
|
|
1612
1621
|
overview = _connector_overview(charger, request.user)
|
|
1613
1622
|
connector_links = [
|
|
1614
1623
|
{
|
|
@@ -1734,7 +1743,7 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1734
1743
|
@csrf_exempt
|
|
1735
1744
|
@api_login_required
|
|
1736
1745
|
def dispatch_action(request, cid, connector=None):
|
|
1737
|
-
connector_value,
|
|
1746
|
+
connector_value, _normalized_slug = _normalize_connector_slug(connector)
|
|
1738
1747
|
log_key = store.identity_key(cid, connector_value)
|
|
1739
1748
|
if connector_value is None:
|
|
1740
1749
|
charger_obj = (
|
|
@@ -1750,11 +1759,11 @@ def dispatch_action(request, cid, connector=None):
|
|
|
1750
1759
|
)
|
|
1751
1760
|
if charger_obj is None:
|
|
1752
1761
|
if connector_value is None:
|
|
1753
|
-
charger_obj,
|
|
1762
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1754
1763
|
charger_id=cid, connector_id=None
|
|
1755
1764
|
)
|
|
1756
1765
|
else:
|
|
1757
|
-
charger_obj,
|
|
1766
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1758
1767
|
charger_id=cid, connector_id=connector_value
|
|
1759
1768
|
)
|
|
1760
1769
|
|
|
@@ -1925,6 +1934,13 @@ def dispatch_action(request, cid, connector=None):
|
|
|
1925
1934
|
},
|
|
1926
1935
|
)
|
|
1927
1936
|
elif action == "reset":
|
|
1937
|
+
tx_obj = store.get_transaction(cid, connector_value)
|
|
1938
|
+
if tx_obj is not None:
|
|
1939
|
+
detail = _(
|
|
1940
|
+
"Reset is blocked while a charging session is active. "
|
|
1941
|
+
"Stop the session first."
|
|
1942
|
+
)
|
|
1943
|
+
return JsonResponse({"detail": detail}, status=409)
|
|
1928
1944
|
message_id = uuid.uuid4().hex
|
|
1929
1945
|
ocpp_action = "Reset"
|
|
1930
1946
|
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|