arthexis 0.1.12__py3-none-any.whl → 0.1.13__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.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
nodes/urls.py
CHANGED
|
@@ -9,5 +9,6 @@ urlpatterns = [
|
|
|
9
9
|
path("screenshot/", views.capture, name="node-screenshot"),
|
|
10
10
|
path("net-message/", views.net_message, name="net-message"),
|
|
11
11
|
path("last-message/", views.last_net_message, name="last-net-message"),
|
|
12
|
+
path("rfid/export/", views.export_rfids, name="node-rfid-export"),
|
|
12
13
|
path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
|
|
13
14
|
]
|
nodes/views.py
CHANGED
|
@@ -8,6 +8,7 @@ from django.http.request import split_domain_port
|
|
|
8
8
|
from django.views.decorators.csrf import csrf_exempt
|
|
9
9
|
from django.shortcuts import get_object_or_404
|
|
10
10
|
from django.conf import settings
|
|
11
|
+
from django.urls import reverse
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from django.utils.cache import patch_vary_headers
|
|
13
14
|
|
|
@@ -16,7 +17,15 @@ from utils.api import api_login_required
|
|
|
16
17
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
17
18
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
18
19
|
|
|
19
|
-
from .models import
|
|
20
|
+
from core.models import RFID
|
|
21
|
+
|
|
22
|
+
from .models import (
|
|
23
|
+
Node,
|
|
24
|
+
NetMessage,
|
|
25
|
+
NodeFeature,
|
|
26
|
+
NodeRole,
|
|
27
|
+
node_information_updated,
|
|
28
|
+
)
|
|
20
29
|
from .utils import capture_screenshot, save_screenshot
|
|
21
30
|
|
|
22
31
|
|
|
@@ -80,6 +89,25 @@ def _get_host_ip(request) -> str:
|
|
|
80
89
|
return domain
|
|
81
90
|
|
|
82
91
|
|
|
92
|
+
def _get_host_domain(request) -> str:
|
|
93
|
+
"""Return the domain from the host header when it isn't an IP."""
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
host = request.get_host()
|
|
97
|
+
except Exception: # pragma: no cover - defensive
|
|
98
|
+
return ""
|
|
99
|
+
if not host:
|
|
100
|
+
return ""
|
|
101
|
+
domain, _ = split_domain_port(host)
|
|
102
|
+
if not domain:
|
|
103
|
+
return ""
|
|
104
|
+
try:
|
|
105
|
+
ipaddress.ip_address(domain)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return domain
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
83
111
|
def _get_advertised_address(request, node) -> str:
|
|
84
112
|
"""Return the best address for the client to reach this node."""
|
|
85
113
|
|
|
@@ -119,9 +147,19 @@ def node_info(request):
|
|
|
119
147
|
node, _ = Node.register_current()
|
|
120
148
|
|
|
121
149
|
token = request.GET.get("token", "")
|
|
122
|
-
|
|
150
|
+
host_domain = _get_host_domain(request)
|
|
151
|
+
advertised_address = _get_advertised_address(request, node)
|
|
152
|
+
if host_domain:
|
|
153
|
+
hostname = host_domain
|
|
154
|
+
if advertised_address and advertised_address != node.address:
|
|
155
|
+
address = advertised_address
|
|
156
|
+
else:
|
|
157
|
+
address = host_domain
|
|
158
|
+
else:
|
|
159
|
+
hostname = node.hostname
|
|
160
|
+
address = advertised_address
|
|
123
161
|
data = {
|
|
124
|
-
"hostname":
|
|
162
|
+
"hostname": hostname,
|
|
125
163
|
"address": address,
|
|
126
164
|
"port": node.port,
|
|
127
165
|
"mac_address": node.mac_address,
|
|
@@ -167,6 +205,33 @@ def _add_cors_headers(request, response):
|
|
|
167
205
|
return response
|
|
168
206
|
|
|
169
207
|
|
|
208
|
+
def _node_display_name(node: Node) -> str:
|
|
209
|
+
"""Return a human-friendly name for ``node`` suitable for messaging."""
|
|
210
|
+
|
|
211
|
+
for attr in ("hostname", "public_endpoint", "address"):
|
|
212
|
+
value = getattr(node, attr, "") or ""
|
|
213
|
+
value = value.strip()
|
|
214
|
+
if value:
|
|
215
|
+
return value
|
|
216
|
+
identifier = getattr(node, "pk", None)
|
|
217
|
+
return str(identifier or node)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
|
|
221
|
+
"""Emit a network message when the visitor node links to a host."""
|
|
222
|
+
|
|
223
|
+
if relation != Node.Relation.UPSTREAM:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
local_node = Node.get_local()
|
|
227
|
+
if not local_node:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
visitor_name = _node_display_name(local_node)
|
|
231
|
+
host_name = _node_display_name(new_node)
|
|
232
|
+
NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
|
|
233
|
+
|
|
234
|
+
|
|
170
235
|
@csrf_exempt
|
|
171
236
|
def register_node(request):
|
|
172
237
|
"""Register or update a node from POSTed JSON data."""
|
|
@@ -321,6 +386,8 @@ def register_node(request):
|
|
|
321
386
|
request=request,
|
|
322
387
|
)
|
|
323
388
|
|
|
389
|
+
_announce_visitor_join(node, relation_value)
|
|
390
|
+
|
|
324
391
|
response = JsonResponse({"id": node.id})
|
|
325
392
|
return _add_cors_headers(request, response)
|
|
326
393
|
|
|
@@ -340,6 +407,64 @@ def capture(request):
|
|
|
340
407
|
return JsonResponse({"screenshot": str(path), "node": node_id})
|
|
341
408
|
|
|
342
409
|
|
|
410
|
+
@csrf_exempt
|
|
411
|
+
def export_rfids(request):
|
|
412
|
+
"""Return serialized RFID records for authenticated peers."""
|
|
413
|
+
|
|
414
|
+
if request.method != "POST":
|
|
415
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
payload = json.loads(request.body.decode() or "{}")
|
|
419
|
+
except json.JSONDecodeError:
|
|
420
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
421
|
+
|
|
422
|
+
requester = payload.get("requester")
|
|
423
|
+
signature = request.headers.get("X-Signature")
|
|
424
|
+
if not requester:
|
|
425
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
426
|
+
if not signature:
|
|
427
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
428
|
+
|
|
429
|
+
node = Node.objects.filter(uuid=requester).first()
|
|
430
|
+
if not node or not node.public_key:
|
|
431
|
+
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
435
|
+
public_key.verify(
|
|
436
|
+
base64.b64decode(signature),
|
|
437
|
+
request.body,
|
|
438
|
+
padding.PKCS1v15(),
|
|
439
|
+
hashes.SHA256(),
|
|
440
|
+
)
|
|
441
|
+
except Exception:
|
|
442
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
443
|
+
|
|
444
|
+
tags = []
|
|
445
|
+
for tag in RFID.objects.all().order_by("label_id"):
|
|
446
|
+
tags.append(
|
|
447
|
+
{
|
|
448
|
+
"rfid": tag.rfid,
|
|
449
|
+
"custom_label": tag.custom_label,
|
|
450
|
+
"key_a": tag.key_a,
|
|
451
|
+
"key_b": tag.key_b,
|
|
452
|
+
"data": tag.data,
|
|
453
|
+
"key_a_verified": tag.key_a_verified,
|
|
454
|
+
"key_b_verified": tag.key_b_verified,
|
|
455
|
+
"allowed": tag.allowed,
|
|
456
|
+
"color": tag.color,
|
|
457
|
+
"kind": tag.kind,
|
|
458
|
+
"released": tag.released,
|
|
459
|
+
"last_seen_on": tag.last_seen_on.isoformat()
|
|
460
|
+
if tag.last_seen_on
|
|
461
|
+
else None,
|
|
462
|
+
}
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return JsonResponse({"rfids": tags})
|
|
466
|
+
|
|
467
|
+
|
|
343
468
|
@csrf_exempt
|
|
344
469
|
@api_login_required
|
|
345
470
|
def public_node_endpoint(request, endpoint):
|
|
@@ -409,6 +534,25 @@ def net_message(request):
|
|
|
409
534
|
reach_role = None
|
|
410
535
|
if reach_name:
|
|
411
536
|
reach_role = NodeRole.objects.filter(name=reach_name).first()
|
|
537
|
+
filter_node_uuid = data.get("filter_node")
|
|
538
|
+
filter_node = None
|
|
539
|
+
if filter_node_uuid:
|
|
540
|
+
filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
|
|
541
|
+
filter_feature_slug = data.get("filter_node_feature")
|
|
542
|
+
filter_feature = None
|
|
543
|
+
if filter_feature_slug:
|
|
544
|
+
filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
|
|
545
|
+
filter_role_name = data.get("filter_node_role")
|
|
546
|
+
filter_role = None
|
|
547
|
+
if filter_role_name:
|
|
548
|
+
filter_role = NodeRole.objects.filter(name=filter_role_name).first()
|
|
549
|
+
filter_relation_value = data.get("filter_current_relation")
|
|
550
|
+
filter_relation = ""
|
|
551
|
+
if filter_relation_value:
|
|
552
|
+
relation = Node.normalize_relation(filter_relation_value)
|
|
553
|
+
filter_relation = relation.value if relation else ""
|
|
554
|
+
filter_installed_version = (data.get("filter_installed_version") or "")[:20]
|
|
555
|
+
filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
|
|
412
556
|
seen = data.get("seen", [])
|
|
413
557
|
origin_id = data.get("origin")
|
|
414
558
|
origin_node = None
|
|
@@ -425,6 +569,12 @@ def net_message(request):
|
|
|
425
569
|
"body": body[:256],
|
|
426
570
|
"reach": reach_role,
|
|
427
571
|
"node_origin": origin_node,
|
|
572
|
+
"filter_node": filter_node,
|
|
573
|
+
"filter_node_feature": filter_feature,
|
|
574
|
+
"filter_node_role": filter_role,
|
|
575
|
+
"filter_current_relation": filter_relation,
|
|
576
|
+
"filter_installed_version": filter_installed_version,
|
|
577
|
+
"filter_installed_revision": filter_installed_revision,
|
|
428
578
|
},
|
|
429
579
|
)
|
|
430
580
|
if not created:
|
|
@@ -437,6 +587,18 @@ def net_message(request):
|
|
|
437
587
|
if msg.node_origin_id is None and origin_node:
|
|
438
588
|
msg.node_origin = origin_node
|
|
439
589
|
update_fields.append("node_origin")
|
|
590
|
+
field_updates = {
|
|
591
|
+
"filter_node": filter_node,
|
|
592
|
+
"filter_node_feature": filter_feature,
|
|
593
|
+
"filter_node_role": filter_role,
|
|
594
|
+
"filter_current_relation": filter_relation,
|
|
595
|
+
"filter_installed_version": filter_installed_version,
|
|
596
|
+
"filter_installed_revision": filter_installed_revision,
|
|
597
|
+
}
|
|
598
|
+
for field, value in field_updates.items():
|
|
599
|
+
if getattr(msg, field) != value:
|
|
600
|
+
setattr(msg, field, value)
|
|
601
|
+
update_fields.append(field)
|
|
440
602
|
msg.save(update_fields=update_fields)
|
|
441
603
|
msg.propagate(seen=seen)
|
|
442
604
|
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
@@ -447,5 +609,11 @@ def last_net_message(request):
|
|
|
447
609
|
|
|
448
610
|
msg = NetMessage.objects.order_by("-created").first()
|
|
449
611
|
if not msg:
|
|
450
|
-
return JsonResponse({"subject": "", "body": ""})
|
|
451
|
-
return JsonResponse(
|
|
612
|
+
return JsonResponse({"subject": "", "body": "", "admin_url": ""})
|
|
613
|
+
return JsonResponse(
|
|
614
|
+
{
|
|
615
|
+
"subject": msg.subject,
|
|
616
|
+
"body": msg.body,
|
|
617
|
+
"admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
|
|
618
|
+
}
|
|
619
|
+
)
|
ocpp/admin.py
CHANGED
|
@@ -28,6 +28,8 @@ from .transactions_io import (
|
|
|
28
28
|
export_transactions,
|
|
29
29
|
import_transactions as import_transactions_data,
|
|
30
30
|
)
|
|
31
|
+
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
32
|
+
from core.admin import SaveBeforeChangeAction
|
|
31
33
|
from core.user_data import EntityModelAdmin
|
|
32
34
|
|
|
33
35
|
|
|
@@ -255,6 +257,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
255
257
|
"change_availability_inoperative",
|
|
256
258
|
"set_availability_state_operative",
|
|
257
259
|
"set_availability_state_inoperative",
|
|
260
|
+
"remote_stop_transaction",
|
|
261
|
+
"reset_chargers",
|
|
258
262
|
"delete_selected",
|
|
259
263
|
]
|
|
260
264
|
|
|
@@ -314,10 +318,35 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
314
318
|
args=[obj.charger_id, obj.connector_slug],
|
|
315
319
|
)
|
|
316
320
|
label = (obj.last_status or "status").strip() or "status"
|
|
321
|
+
status_key = label.lower()
|
|
322
|
+
error_code = (obj.last_error_code or "").strip().lower()
|
|
323
|
+
if (
|
|
324
|
+
self._has_active_session(obj)
|
|
325
|
+
and error_code in ERROR_OK_VALUES
|
|
326
|
+
and (status_key not in STATUS_BADGE_MAP or status_key == "available")
|
|
327
|
+
):
|
|
328
|
+
label = STATUS_BADGE_MAP["charging"][0]
|
|
317
329
|
return format_html('<a href="{}" target="_blank">{}</a>', url, label)
|
|
318
330
|
|
|
319
331
|
status_link.short_description = "Status"
|
|
320
332
|
|
|
333
|
+
def _has_active_session(self, charger: Charger) -> bool:
|
|
334
|
+
"""Return whether ``charger`` currently has an active session."""
|
|
335
|
+
|
|
336
|
+
if store.get_transaction(charger.charger_id, charger.connector_id):
|
|
337
|
+
return True
|
|
338
|
+
if charger.connector_id is not None:
|
|
339
|
+
return False
|
|
340
|
+
sibling_connectors = (
|
|
341
|
+
Charger.objects.filter(charger_id=charger.charger_id)
|
|
342
|
+
.exclude(pk=charger.pk)
|
|
343
|
+
.values_list("connector_id", flat=True)
|
|
344
|
+
)
|
|
345
|
+
for connector_id in sibling_connectors:
|
|
346
|
+
if store.get_transaction(charger.charger_id, connector_id):
|
|
347
|
+
return True
|
|
348
|
+
return False
|
|
349
|
+
|
|
321
350
|
def location_name(self, obj):
|
|
322
351
|
return obj.location.name if obj.location else ""
|
|
323
352
|
|
|
@@ -475,6 +504,112 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
475
504
|
def set_availability_state_inoperative(self, request, queryset):
|
|
476
505
|
self._set_availability_state(request, queryset, "Inoperative")
|
|
477
506
|
|
|
507
|
+
@admin.action(description="Remote stop active transaction")
|
|
508
|
+
def remote_stop_transaction(self, request, queryset):
|
|
509
|
+
stopped = 0
|
|
510
|
+
for charger in queryset:
|
|
511
|
+
connector_value = charger.connector_id
|
|
512
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
513
|
+
if ws is None:
|
|
514
|
+
self.message_user(
|
|
515
|
+
request,
|
|
516
|
+
f"{charger}: no active connection",
|
|
517
|
+
level=messages.ERROR,
|
|
518
|
+
)
|
|
519
|
+
continue
|
|
520
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
521
|
+
if tx_obj is None:
|
|
522
|
+
self.message_user(
|
|
523
|
+
request,
|
|
524
|
+
f"{charger}: no active transaction",
|
|
525
|
+
level=messages.ERROR,
|
|
526
|
+
)
|
|
527
|
+
continue
|
|
528
|
+
message_id = uuid.uuid4().hex
|
|
529
|
+
payload = {"transactionId": tx_obj.pk}
|
|
530
|
+
msg = json.dumps([
|
|
531
|
+
2,
|
|
532
|
+
message_id,
|
|
533
|
+
"RemoteStopTransaction",
|
|
534
|
+
payload,
|
|
535
|
+
])
|
|
536
|
+
try:
|
|
537
|
+
async_to_sync(ws.send)(msg)
|
|
538
|
+
except Exception as exc: # pragma: no cover - network error
|
|
539
|
+
self.message_user(
|
|
540
|
+
request,
|
|
541
|
+
f"{charger}: failed to send RemoteStopTransaction ({exc})",
|
|
542
|
+
level=messages.ERROR,
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
546
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
547
|
+
store.register_pending_call(
|
|
548
|
+
message_id,
|
|
549
|
+
{
|
|
550
|
+
"action": "RemoteStopTransaction",
|
|
551
|
+
"charger_id": charger.charger_id,
|
|
552
|
+
"connector_id": connector_value,
|
|
553
|
+
"transaction_id": tx_obj.pk,
|
|
554
|
+
"log_key": log_key,
|
|
555
|
+
"requested_at": timezone.now(),
|
|
556
|
+
},
|
|
557
|
+
)
|
|
558
|
+
stopped += 1
|
|
559
|
+
if stopped:
|
|
560
|
+
self.message_user(
|
|
561
|
+
request,
|
|
562
|
+
f"Sent RemoteStopTransaction to {stopped} charger(s)",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
@admin.action(description="Reset charger (soft)")
|
|
566
|
+
def reset_chargers(self, request, queryset):
|
|
567
|
+
reset = 0
|
|
568
|
+
for charger in queryset:
|
|
569
|
+
connector_value = charger.connector_id
|
|
570
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
571
|
+
if ws is None:
|
|
572
|
+
self.message_user(
|
|
573
|
+
request,
|
|
574
|
+
f"{charger}: no active connection",
|
|
575
|
+
level=messages.ERROR,
|
|
576
|
+
)
|
|
577
|
+
continue
|
|
578
|
+
message_id = uuid.uuid4().hex
|
|
579
|
+
msg = json.dumps([
|
|
580
|
+
2,
|
|
581
|
+
message_id,
|
|
582
|
+
"Reset",
|
|
583
|
+
{"type": "Soft"},
|
|
584
|
+
])
|
|
585
|
+
try:
|
|
586
|
+
async_to_sync(ws.send)(msg)
|
|
587
|
+
except Exception as exc: # pragma: no cover - network error
|
|
588
|
+
self.message_user(
|
|
589
|
+
request,
|
|
590
|
+
f"{charger}: failed to send Reset ({exc})",
|
|
591
|
+
level=messages.ERROR,
|
|
592
|
+
)
|
|
593
|
+
continue
|
|
594
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
595
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
596
|
+
store.register_pending_call(
|
|
597
|
+
message_id,
|
|
598
|
+
{
|
|
599
|
+
"action": "Reset",
|
|
600
|
+
"charger_id": charger.charger_id,
|
|
601
|
+
"connector_id": connector_value,
|
|
602
|
+
"log_key": log_key,
|
|
603
|
+
"requested_at": timezone.now(),
|
|
604
|
+
},
|
|
605
|
+
)
|
|
606
|
+
reset += 1
|
|
607
|
+
if reset:
|
|
608
|
+
self.message_user(
|
|
609
|
+
request,
|
|
610
|
+
f"Sent Reset to {reset} charger(s)",
|
|
611
|
+
)
|
|
612
|
+
|
|
478
613
|
def delete_queryset(self, request, queryset):
|
|
479
614
|
for obj in queryset:
|
|
480
615
|
obj.delete()
|
|
@@ -494,7 +629,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
494
629
|
|
|
495
630
|
|
|
496
631
|
@admin.register(Simulator)
|
|
497
|
-
class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
632
|
+
class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
|
|
498
633
|
list_display = (
|
|
499
634
|
"name",
|
|
500
635
|
"cp_path",
|
|
@@ -538,6 +673,7 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
538
673
|
),
|
|
539
674
|
)
|
|
540
675
|
actions = ("start_simulator", "stop_simulator", "send_open_door")
|
|
676
|
+
change_actions = ["start_simulator_action", "stop_simulator_action"]
|
|
541
677
|
|
|
542
678
|
log_type = "simulator"
|
|
543
679
|
|
|
@@ -650,6 +786,14 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
650
786
|
|
|
651
787
|
stop_simulator.short_description = "Stop selected simulators"
|
|
652
788
|
|
|
789
|
+
def start_simulator_action(self, request, obj):
|
|
790
|
+
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
791
|
+
self.start_simulator(request, queryset)
|
|
792
|
+
|
|
793
|
+
def stop_simulator_action(self, request, obj):
|
|
794
|
+
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
795
|
+
self.stop_simulator(request, queryset)
|
|
796
|
+
|
|
653
797
|
def log_link(self, obj):
|
|
654
798
|
from django.utils.html import format_html
|
|
655
799
|
from django.urls import reverse
|
|
@@ -694,7 +838,7 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
694
838
|
"stop_time",
|
|
695
839
|
"kw",
|
|
696
840
|
)
|
|
697
|
-
readonly_fields = ("kw",)
|
|
841
|
+
readonly_fields = ("kw", "received_start_time", "received_stop_time")
|
|
698
842
|
list_filter = ("charger", "account")
|
|
699
843
|
date_hierarchy = "start_time"
|
|
700
844
|
inlines = [MeterValueInline]
|
ocpp/consumers.py
CHANGED
|
@@ -5,6 +5,7 @@ from datetime import datetime
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import inspect
|
|
7
7
|
import json
|
|
8
|
+
from urllib.parse import parse_qs
|
|
8
9
|
from django.utils import timezone
|
|
9
10
|
from core.models import EnergyAccount, Reference, RFID as CoreRFID
|
|
10
11
|
from nodes.models import NetMessage
|
|
@@ -31,6 +32,18 @@ from .evcs_discovery import (
|
|
|
31
32
|
FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
# Query parameter keys that may contain the charge point serial. Keys are
|
|
36
|
+
# matched case-insensitively and trimmed before use.
|
|
37
|
+
SERIAL_QUERY_PARAM_NAMES = (
|
|
38
|
+
"cid",
|
|
39
|
+
"chargepointid",
|
|
40
|
+
"charge_point_id",
|
|
41
|
+
"chargeboxid",
|
|
42
|
+
"charge_box_id",
|
|
43
|
+
"chargerid",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
34
47
|
def _parse_ip(value: str | None):
|
|
35
48
|
"""Return an :mod:`ipaddress` object for the provided value, if valid."""
|
|
36
49
|
|
|
@@ -105,6 +118,22 @@ def _resolve_client_ip(scope: dict) -> str | None:
|
|
|
105
118
|
return fallback
|
|
106
119
|
|
|
107
120
|
|
|
121
|
+
def _parse_ocpp_timestamp(value) -> datetime | None:
|
|
122
|
+
"""Return an aware :class:`~datetime.datetime` for OCPP timestamps."""
|
|
123
|
+
|
|
124
|
+
if not value:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(value, datetime):
|
|
127
|
+
timestamp = value
|
|
128
|
+
else:
|
|
129
|
+
timestamp = parse_datetime(str(value))
|
|
130
|
+
if not timestamp:
|
|
131
|
+
return None
|
|
132
|
+
if timezone.is_naive(timestamp):
|
|
133
|
+
timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
|
|
134
|
+
return timestamp
|
|
135
|
+
|
|
136
|
+
|
|
108
137
|
class SinkConsumer(AsyncWebsocketConsumer):
|
|
109
138
|
"""Accept any message without validation."""
|
|
110
139
|
|
|
@@ -137,15 +166,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
137
166
|
|
|
138
167
|
consumption_update_interval = 300
|
|
139
168
|
|
|
169
|
+
def _extract_serial_identifier(self) -> str:
|
|
170
|
+
"""Return the charge point serial from the query string or path."""
|
|
171
|
+
|
|
172
|
+
self.serial_source = None
|
|
173
|
+
query_bytes = self.scope.get("query_string") or b""
|
|
174
|
+
self._raw_query_string = query_bytes.decode("utf-8", "ignore") if query_bytes else ""
|
|
175
|
+
if query_bytes:
|
|
176
|
+
try:
|
|
177
|
+
parsed = parse_qs(
|
|
178
|
+
self._raw_query_string,
|
|
179
|
+
keep_blank_values=False,
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
parsed = {}
|
|
183
|
+
if parsed:
|
|
184
|
+
normalized = {
|
|
185
|
+
key.lower(): values for key, values in parsed.items() if values
|
|
186
|
+
}
|
|
187
|
+
for candidate in SERIAL_QUERY_PARAM_NAMES:
|
|
188
|
+
values = normalized.get(candidate)
|
|
189
|
+
if not values:
|
|
190
|
+
continue
|
|
191
|
+
for value in values:
|
|
192
|
+
if not value:
|
|
193
|
+
continue
|
|
194
|
+
trimmed = value.strip()
|
|
195
|
+
if trimmed:
|
|
196
|
+
return trimmed
|
|
197
|
+
|
|
198
|
+
return self.scope["url_route"]["kwargs"].get("cid", "")
|
|
199
|
+
|
|
140
200
|
@requires_network
|
|
141
201
|
async def connect(self):
|
|
142
|
-
raw_serial = self.
|
|
202
|
+
raw_serial = self._extract_serial_identifier()
|
|
143
203
|
try:
|
|
144
204
|
self.charger_id = Charger.validate_serial(raw_serial)
|
|
145
205
|
except ValidationError as exc:
|
|
146
206
|
serial = Charger.normalize_serial(raw_serial)
|
|
147
207
|
store_key = store.pending_key(serial)
|
|
148
208
|
message = exc.messages[0] if exc.messages else "Invalid Serial Number"
|
|
209
|
+
details: list[str] = []
|
|
210
|
+
if getattr(self, "serial_source", None):
|
|
211
|
+
details.append(f"serial_source={self.serial_source}")
|
|
212
|
+
if getattr(self, "_raw_query_string", ""):
|
|
213
|
+
details.append(f"query_string={self._raw_query_string!r}")
|
|
214
|
+
if details:
|
|
215
|
+
message = f"{message} ({'; '.join(details)})"
|
|
149
216
|
store.add_log(
|
|
150
217
|
store_key,
|
|
151
218
|
f"Rejected connection: {message}",
|
|
@@ -218,6 +285,37 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
218
285
|
).first
|
|
219
286
|
)()
|
|
220
287
|
|
|
288
|
+
async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
|
|
289
|
+
"""Ensure an RFID record exists and update its last seen timestamp."""
|
|
290
|
+
|
|
291
|
+
if not id_tag:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
normalized = id_tag.upper()
|
|
295
|
+
|
|
296
|
+
def _ensure() -> CoreRFID:
|
|
297
|
+
now = timezone.now()
|
|
298
|
+
tag, created = CoreRFID.objects.get_or_create(
|
|
299
|
+
rfid=normalized,
|
|
300
|
+
defaults={"allowed": False, "last_seen_on": now},
|
|
301
|
+
)
|
|
302
|
+
if created:
|
|
303
|
+
updates = []
|
|
304
|
+
if tag.allowed:
|
|
305
|
+
tag.allowed = False
|
|
306
|
+
updates.append("allowed")
|
|
307
|
+
if tag.last_seen_on != now:
|
|
308
|
+
tag.last_seen_on = now
|
|
309
|
+
updates.append("last_seen_on")
|
|
310
|
+
if updates:
|
|
311
|
+
tag.save(update_fields=updates)
|
|
312
|
+
else:
|
|
313
|
+
tag.last_seen_on = now
|
|
314
|
+
tag.save(update_fields=["last_seen_on"])
|
|
315
|
+
return tag
|
|
316
|
+
|
|
317
|
+
return await database_sync_to_async(_ensure)()
|
|
318
|
+
|
|
221
319
|
async def _assign_connector(self, connector: int | str | None) -> None:
|
|
222
320
|
"""Ensure ``self.charger`` matches the provided connector id."""
|
|
223
321
|
if connector in (None, "", "-"):
|
|
@@ -1133,6 +1231,12 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1133
1231
|
)(**update_kwargs)
|
|
1134
1232
|
_update_instance(self.aggregate_charger)
|
|
1135
1233
|
_update_instance(self.charger)
|
|
1234
|
+
if connector_value is not None and status.lower() == "available":
|
|
1235
|
+
tx_obj = store.transactions.pop(self.store_key, None)
|
|
1236
|
+
if tx_obj:
|
|
1237
|
+
await self._cancel_consumption_message()
|
|
1238
|
+
store.end_session_log(self.store_key)
|
|
1239
|
+
store.stop_session_lock()
|
|
1136
1240
|
store.add_log(
|
|
1137
1241
|
self.store_key,
|
|
1138
1242
|
f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
|
|
@@ -1145,7 +1249,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1145
1249
|
)
|
|
1146
1250
|
reply_payload = {}
|
|
1147
1251
|
elif action == "Authorize":
|
|
1148
|
-
|
|
1252
|
+
id_tag = payload.get("idTag")
|
|
1253
|
+
account = await self._get_account(id_tag)
|
|
1149
1254
|
if self.charger.require_rfid:
|
|
1150
1255
|
status = (
|
|
1151
1256
|
"Accepted"
|
|
@@ -1154,6 +1259,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1154
1259
|
else "Invalid"
|
|
1155
1260
|
)
|
|
1156
1261
|
else:
|
|
1262
|
+
await self._ensure_rfid_seen(id_tag)
|
|
1157
1263
|
status = "Accepted"
|
|
1158
1264
|
reply_payload = {"idTagInfo": {"status": status}}
|
|
1159
1265
|
elif action == "MeterValues":
|
|
@@ -1227,9 +1333,12 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1227
1333
|
id_tag = payload.get("idTag")
|
|
1228
1334
|
account = await self._get_account(id_tag)
|
|
1229
1335
|
if id_tag:
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1336
|
+
if self.charger.require_rfid:
|
|
1337
|
+
await database_sync_to_async(CoreRFID.objects.get_or_create)(
|
|
1338
|
+
rfid=id_tag.upper()
|
|
1339
|
+
)
|
|
1340
|
+
else:
|
|
1341
|
+
await self._ensure_rfid_seen(id_tag)
|
|
1233
1342
|
await self._assign_connector(payload.get("connectorId"))
|
|
1234
1343
|
if self.charger.require_rfid:
|
|
1235
1344
|
authorized = (
|
|
@@ -1239,6 +1348,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1239
1348
|
else:
|
|
1240
1349
|
authorized = True
|
|
1241
1350
|
if authorized:
|
|
1351
|
+
start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1352
|
+
received_start = timezone.now()
|
|
1242
1353
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1243
1354
|
charger=self.charger,
|
|
1244
1355
|
account=account,
|
|
@@ -1246,7 +1357,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1246
1357
|
vin=(payload.get("vin") or ""),
|
|
1247
1358
|
connector_id=payload.get("connectorId"),
|
|
1248
1359
|
meter_start=payload.get("meterStart"),
|
|
1249
|
-
start_time=
|
|
1360
|
+
start_time=start_timestamp or received_start,
|
|
1361
|
+
received_start_time=received_start,
|
|
1250
1362
|
)
|
|
1251
1363
|
store.transactions[self.store_key] = tx_obj
|
|
1252
1364
|
store.start_session_log(self.store_key, tx_obj.pk)
|
|
@@ -1267,17 +1379,22 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1267
1379
|
Transaction.objects.filter(pk=tx_id, charger=self.charger).first
|
|
1268
1380
|
)()
|
|
1269
1381
|
if not tx_obj and tx_id is not None:
|
|
1382
|
+
received_start = timezone.now()
|
|
1270
1383
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1271
1384
|
pk=tx_id,
|
|
1272
1385
|
charger=self.charger,
|
|
1273
|
-
start_time=
|
|
1386
|
+
start_time=received_start,
|
|
1387
|
+
received_start_time=received_start,
|
|
1274
1388
|
meter_start=payload.get("meterStart")
|
|
1275
1389
|
or payload.get("meterStop"),
|
|
1276
1390
|
vin=(payload.get("vin") or ""),
|
|
1277
1391
|
)
|
|
1278
1392
|
if tx_obj:
|
|
1393
|
+
stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1394
|
+
received_stop = timezone.now()
|
|
1279
1395
|
tx_obj.meter_stop = payload.get("meterStop")
|
|
1280
|
-
tx_obj.stop_time =
|
|
1396
|
+
tx_obj.stop_time = stop_timestamp or received_stop
|
|
1397
|
+
tx_obj.received_stop_time = received_stop
|
|
1281
1398
|
await database_sync_to_async(tx_obj.save)()
|
|
1282
1399
|
await self._update_consumption_message(tx_obj.pk)
|
|
1283
1400
|
await self._cancel_consumption_message()
|