arthexis 0.1.3__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.

Files changed (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. pages/views.py +191 -0
ocpp/admin.py ADDED
@@ -0,0 +1,392 @@
1
+ from django.contrib import admin
2
+ from django import forms
3
+
4
+ import asyncio
5
+ from datetime import timedelta
6
+ import json
7
+
8
+ from django.utils import timezone
9
+ from django.urls import path
10
+ from django.http import HttpResponse, HttpResponseRedirect
11
+ from django.template.response import TemplateResponse
12
+
13
+ from .models import (
14
+ Charger,
15
+ Simulator,
16
+ MeterReading,
17
+ Transaction,
18
+ Location,
19
+ ElectricVehicle,
20
+ )
21
+ from .simulator import ChargePointSimulator
22
+ from . import store
23
+ from .transactions_io import (
24
+ export_transactions,
25
+ import_transactions as import_transactions_data,
26
+ )
27
+ from core.admin import RFIDAdmin
28
+ from .models import RFID
29
+
30
+
31
+ class LocationAdminForm(forms.ModelForm):
32
+ class Meta:
33
+ model = Location
34
+ fields = "__all__"
35
+
36
+ widgets = {
37
+ "latitude": forms.NumberInput(attrs={"step": "any"}),
38
+ "longitude": forms.NumberInput(attrs={"step": "any"}),
39
+ }
40
+
41
+ class Media:
42
+ css = {
43
+ "all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)
44
+ }
45
+ js = (
46
+ "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
47
+ "ocpp/charger_map.js",
48
+ )
49
+
50
+
51
+ class TransactionExportForm(forms.Form):
52
+ start = forms.DateTimeField(required=False)
53
+ end = forms.DateTimeField(required=False)
54
+ chargers = forms.ModelMultipleChoiceField(
55
+ queryset=Charger.objects.all(), required=False
56
+ )
57
+
58
+
59
+ class TransactionImportForm(forms.Form):
60
+ file = forms.FileField()
61
+
62
+
63
+ @admin.register(Location)
64
+ class LocationAdmin(admin.ModelAdmin):
65
+ form = LocationAdminForm
66
+ list_display = ("name", "latitude", "longitude")
67
+
68
+
69
+ @admin.register(Charger)
70
+ class ChargerAdmin(admin.ModelAdmin):
71
+ fieldsets = (
72
+ (
73
+ "General",
74
+ {
75
+ "fields": (
76
+ "charger_id",
77
+ "connector_id",
78
+ "require_rfid",
79
+ "last_heartbeat",
80
+ "last_meter_values",
81
+ "last_path",
82
+ "location",
83
+ )
84
+ },
85
+ ),
86
+ (
87
+ "References",
88
+ {
89
+ "fields": ("reference",),
90
+ },
91
+ ),
92
+ )
93
+ readonly_fields = ("last_heartbeat", "last_meter_values")
94
+ list_display = (
95
+ "charger_id",
96
+ "connector_id",
97
+ "location_name",
98
+ "require_rfid",
99
+ "last_heartbeat",
100
+ "session_kw",
101
+ "total_kw_display",
102
+ "page_link",
103
+ "log_link",
104
+ "status_link",
105
+ )
106
+ search_fields = ("charger_id", "connector_id", "location__name")
107
+ actions = ["purge_data", "delete_selected"]
108
+
109
+ def get_view_on_site_url(self, obj=None):
110
+ return obj.get_absolute_url() if obj else None
111
+
112
+ def page_link(self, obj):
113
+ from django.utils.html import format_html
114
+
115
+ return format_html(
116
+ '<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
117
+ )
118
+
119
+ page_link.short_description = "Landing Page"
120
+
121
+ def qr_link(self, obj):
122
+ from django.utils.html import format_html
123
+
124
+ if obj.reference and obj.reference.image:
125
+ return format_html(
126
+ '<a href="{}" target="_blank">qr</a>', obj.reference.image.url
127
+ )
128
+ return ""
129
+
130
+ qr_link.short_description = "QR Code"
131
+
132
+ def log_link(self, obj):
133
+ from django.utils.html import format_html
134
+ from django.urls import reverse
135
+
136
+ url = reverse("charger-log", args=[obj.charger_id]) + "?type=charger"
137
+ return format_html('<a href="{}" target="_blank">view</a>', url)
138
+
139
+ log_link.short_description = "Log"
140
+
141
+ def status_link(self, obj):
142
+ from django.utils.html import format_html
143
+ from django.urls import reverse
144
+
145
+ url = reverse("charger-status", args=[obj.charger_id])
146
+ return format_html('<a href="{}" target="_blank">status</a>', url)
147
+
148
+ status_link.short_description = "Status Page"
149
+
150
+ def location_name(self, obj):
151
+ return obj.location.name if obj.location else ""
152
+
153
+ location_name.short_description = "Location"
154
+
155
+ def purge_data(self, request, queryset):
156
+ for charger in queryset:
157
+ charger.purge()
158
+ self.message_user(request, "Data purged for selected chargers")
159
+
160
+ purge_data.short_description = "Purge data"
161
+
162
+ def delete_queryset(self, request, queryset):
163
+ for obj in queryset:
164
+ obj.delete()
165
+
166
+ def total_kw_display(self, obj):
167
+ return round(obj.total_kw, 2)
168
+
169
+ total_kw_display.short_description = "Total kW"
170
+
171
+ def session_kw(self, obj):
172
+ tx = store.transactions.get(obj.charger_id)
173
+ if tx:
174
+ return round(tx.kw, 2)
175
+ return 0.0
176
+
177
+ session_kw.short_description = "Session kW"
178
+
179
+
180
+ @admin.register(Simulator)
181
+ class SimulatorAdmin(admin.ModelAdmin):
182
+ list_display = (
183
+ "name",
184
+ "cp_path",
185
+ "host",
186
+ "ws_port",
187
+ "ws_url",
188
+ "interval",
189
+ "kw_max",
190
+ "running",
191
+ "log_link",
192
+ )
193
+ fields = (
194
+ "name",
195
+ "cp_path",
196
+ ("host", "ws_port"),
197
+ "rfid",
198
+ ("duration", "interval", "pre_charge_delay"),
199
+ "kw_max",
200
+ "repeat",
201
+ ("username", "password"),
202
+ )
203
+ actions = ("start_simulator", "stop_simulator")
204
+
205
+ def running(self, obj):
206
+ return obj.pk in store.simulators
207
+
208
+ running.boolean = True
209
+
210
+ def start_simulator(self, request, queryset):
211
+ for obj in queryset:
212
+ if obj.pk in store.simulators:
213
+ self.message_user(request, f"{obj.name}: already running")
214
+ continue
215
+ store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
216
+ sim = ChargePointSimulator(obj.as_config())
217
+ started, status, log_file = sim.start()
218
+ if started:
219
+ store.simulators[obj.pk] = sim
220
+ self.message_user(
221
+ request, f"{obj.name}: {status}. Log: {log_file}"
222
+ )
223
+
224
+ start_simulator.short_description = "Start selected simulators"
225
+
226
+ def stop_simulator(self, request, queryset):
227
+ async def _stop(objs):
228
+ for obj in objs:
229
+ sim = store.simulators.pop(obj.pk, None)
230
+ if sim:
231
+ await sim.stop()
232
+
233
+ asyncio.get_event_loop().create_task(_stop(list(queryset)))
234
+ self.message_user(request, "Stopping simulators")
235
+
236
+ stop_simulator.short_description = "Stop selected simulators"
237
+
238
+ def log_link(self, obj):
239
+ from django.utils.html import format_html
240
+ from django.urls import reverse
241
+
242
+ url = reverse("charger-log", args=[obj.cp_path]) + "?type=simulator"
243
+ return format_html('<a href="{}" target="_blank">view</a>', url)
244
+
245
+ log_link.short_description = "Log"
246
+
247
+
248
+ class MeterReadingInline(admin.TabularInline):
249
+ model = MeterReading
250
+ extra = 0
251
+ fields = ("timestamp", "value", "unit", "measurand", "connector_id")
252
+ readonly_fields = fields
253
+ can_delete = False
254
+
255
+
256
+ @admin.register(Transaction)
257
+ class TransactionAdmin(admin.ModelAdmin):
258
+ change_list_template = "admin/ocpp/transaction/change_list.html"
259
+ list_display = (
260
+ "charger",
261
+ "account",
262
+ "rfid",
263
+ "meter_start",
264
+ "meter_stop",
265
+ "start_time",
266
+ "stop_time",
267
+ "kw",
268
+ )
269
+ readonly_fields = ("kw",)
270
+ list_filter = ("charger", "account")
271
+ date_hierarchy = "start_time"
272
+ inlines = [MeterReadingInline]
273
+
274
+ def get_urls(self):
275
+ urls = super().get_urls()
276
+ custom = [
277
+ path(
278
+ "export/",
279
+ self.admin_site.admin_view(self.export_view),
280
+ name="ocpp_transaction_export",
281
+ ),
282
+ path(
283
+ "import/",
284
+ self.admin_site.admin_view(self.import_view),
285
+ name="ocpp_transaction_import",
286
+ ),
287
+ ]
288
+ return custom + urls
289
+
290
+ def export_view(self, request):
291
+ if request.method == "POST":
292
+ form = TransactionExportForm(request.POST)
293
+ if form.is_valid():
294
+ chargers = form.cleaned_data["chargers"]
295
+ data = export_transactions(
296
+ start=form.cleaned_data["start"],
297
+ end=form.cleaned_data["end"],
298
+ chargers=[c.charger_id for c in chargers] if chargers else None,
299
+ )
300
+ response = HttpResponse(
301
+ json.dumps(data, indent=2, ensure_ascii=False),
302
+ content_type="application/json",
303
+ )
304
+ response[
305
+ "Content-Disposition"
306
+ ] = "attachment; filename=transactions.json"
307
+ return response
308
+ else:
309
+ form = TransactionExportForm()
310
+ context = self.admin_site.each_context(request)
311
+ context["form"] = form
312
+ return TemplateResponse(
313
+ request, "admin/ocpp/transaction/export.html", context
314
+ )
315
+
316
+ def import_view(self, request):
317
+ if request.method == "POST":
318
+ form = TransactionImportForm(request.POST, request.FILES)
319
+ if form.is_valid():
320
+ data = json.load(form.cleaned_data["file"])
321
+ imported = import_transactions_data(data)
322
+ self.message_user(request, f"Imported {imported} transactions")
323
+ return HttpResponseRedirect("../")
324
+ else:
325
+ form = TransactionImportForm()
326
+ context = self.admin_site.each_context(request)
327
+ context["form"] = form
328
+ return TemplateResponse(
329
+ request, "admin/ocpp/transaction/import.html", context
330
+ )
331
+
332
+
333
+ class MeterReadingDateFilter(admin.SimpleListFilter):
334
+ title = "Timestamp"
335
+ parameter_name = "timestamp_range"
336
+
337
+ def lookups(self, request, model_admin):
338
+ return [
339
+ ("today", "Today"),
340
+ ("7days", "Last 7 days"),
341
+ ("30days", "Last 30 days"),
342
+ ("older", "Older than 30 days"),
343
+ ]
344
+
345
+ def queryset(self, request, queryset):
346
+ value = self.value()
347
+ now = timezone.now()
348
+ if value == "today":
349
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
350
+ end = start + timedelta(days=1)
351
+ return queryset.filter(timestamp__gte=start, timestamp__lt=end)
352
+ if value == "7days":
353
+ start = now - timedelta(days=7)
354
+ return queryset.filter(timestamp__gte=start)
355
+ if value == "30days":
356
+ start = now - timedelta(days=30)
357
+ return queryset.filter(timestamp__gte=start)
358
+ if value == "older":
359
+ cutoff = now - timedelta(days=30)
360
+ return queryset.filter(timestamp__lt=cutoff)
361
+ return queryset
362
+
363
+
364
+ @admin.register(MeterReading)
365
+ class MeterReadingAdmin(admin.ModelAdmin):
366
+ list_display = (
367
+ "charger",
368
+ "timestamp",
369
+ "value",
370
+ "unit",
371
+ "connector_id",
372
+ "transaction",
373
+ )
374
+ date_hierarchy = "timestamp"
375
+ list_filter = ("charger", MeterReadingDateFilter)
376
+
377
+
378
+ @admin.register(ElectricVehicle)
379
+ class ElectricVehicleAdmin(admin.ModelAdmin):
380
+ list_display = ("vin", "license_plate", "brand", "model", "account")
381
+ search_fields = (
382
+ "vin",
383
+ "license_plate",
384
+ "brand__name",
385
+ "model__name",
386
+ "account__name",
387
+ )
388
+ fields = ("account", "vin", "license_plate", "brand", "model")
389
+
390
+
391
+ admin.site.register(RFID, RFIDAdmin)
392
+
ocpp/apps.py ADDED
@@ -0,0 +1,24 @@
1
+ from django.apps import AppConfig
2
+ from pathlib import Path
3
+ from django.conf import settings
4
+
5
+
6
+ class OcppConfig(AppConfig):
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+ name = "ocpp"
9
+ verbose_name = "OCPP"
10
+
11
+ def ready(self): # pragma: no cover - startup side effects
12
+ lock = Path(settings.BASE_DIR) / "locks" / "control.lck"
13
+ if not lock.exists():
14
+ return
15
+ from .rfid.background_reader import start
16
+ from .rfid.signals import tag_scanned
17
+ from core.notifications import notify
18
+
19
+ def _notify(_sender, rfid=None, **_kwargs):
20
+ if rfid:
21
+ notify("RFID", str(rfid))
22
+
23
+ tag_scanned.connect(_notify, weak=False)
24
+ start()
ocpp/consumers.py ADDED
@@ -0,0 +1,267 @@
1
+ import asyncio
2
+ import json
3
+ import base64
4
+ from datetime import datetime
5
+ from django.utils import timezone
6
+ from core.models import EnergyAccount
7
+
8
+ from channels.generic.websocket import AsyncWebsocketConsumer
9
+ from channels.db import database_sync_to_async
10
+ from asgiref.sync import sync_to_async
11
+ from config.offline import requires_network
12
+
13
+ from . import store
14
+ from decimal import Decimal
15
+ from django.utils.dateparse import parse_datetime
16
+ from .models import Transaction, Charger, MeterReading
17
+
18
+
19
+ class SinkConsumer(AsyncWebsocketConsumer):
20
+ """Accept any message without validation."""
21
+
22
+ @requires_network
23
+ async def connect(self) -> None:
24
+ await self.accept()
25
+
26
+ async def receive(self, text_data: str | None = None, bytes_data: bytes | None = None) -> None:
27
+ if text_data is None:
28
+ return
29
+ try:
30
+ msg = json.loads(text_data)
31
+ if isinstance(msg, list) and msg and msg[0] == 2:
32
+ await self.send(json.dumps([3, msg[1], {}]))
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ class CSMSConsumer(AsyncWebsocketConsumer):
38
+ """Very small subset of OCPP 1.6 CSMS behaviour."""
39
+
40
+ @requires_network
41
+ async def connect(self):
42
+ self.charger_id = self.scope["url_route"]["kwargs"].get("cid", "")
43
+ subprotocol = None
44
+ offered = self.scope.get("subprotocols", [])
45
+ if "ocpp1.6" in offered:
46
+ subprotocol = "ocpp1.6"
47
+ # If a connection for this charger already exists, close it so a new
48
+ # simulator session can start immediately.
49
+ existing = store.connections.get(self.charger_id)
50
+ if existing is not None:
51
+ await existing.close()
52
+ await self.accept(subprotocol=subprotocol)
53
+ store.add_log(
54
+ self.charger_id,
55
+ f"Connected (subprotocol={subprotocol or 'none'})",
56
+ log_type="charger",
57
+ )
58
+ store.connections[self.charger_id] = self
59
+ store.logs["charger"].setdefault(self.charger_id, [])
60
+ self.charger, _ = await database_sync_to_async(
61
+ Charger.objects.update_or_create
62
+ )(
63
+ charger_id=self.charger_id,
64
+ defaults={"last_path": self.scope.get("path", "")},
65
+ )
66
+ location_name = await sync_to_async(
67
+ lambda: self.charger.location.name if self.charger.location else ""
68
+ )()
69
+ store.register_log_name(
70
+ self.charger_id, location_name or self.charger_id, log_type="charger"
71
+ )
72
+
73
+ async def _get_account(self, id_tag: str) -> EnergyAccount | None:
74
+ """Return the energy account for the provided RFID if valid."""
75
+ if not id_tag:
76
+ return None
77
+ return await database_sync_to_async(
78
+ EnergyAccount.objects.filter(
79
+ rfids__rfid=id_tag.upper(), rfids__allowed=True
80
+ ).first
81
+ )()
82
+
83
+ async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
84
+ """Parse a MeterValues payload into MeterReading rows."""
85
+ connector = payload.get("connectorId")
86
+ tx_id = payload.get("transactionId")
87
+ tx_obj = None
88
+ if tx_id is not None:
89
+ # Look up an existing transaction, first in the in-memory store
90
+ # then in the database. If none exists create one so that meter
91
+ # readings can be linked to it.
92
+ tx_obj = store.transactions.get(self.charger_id)
93
+ if not tx_obj or tx_obj.pk != int(tx_id):
94
+ tx_obj = await database_sync_to_async(
95
+ Transaction.objects.filter(pk=tx_id, charger=self.charger).first
96
+ )()
97
+ if tx_obj is None:
98
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
99
+ pk=tx_id, charger=self.charger, start_time=timezone.now()
100
+ )
101
+ store.start_session_log(self.charger_id, tx_obj.pk)
102
+ store.add_session_message(self.charger_id, raw_message)
103
+ store.transactions[self.charger_id] = tx_obj
104
+ else:
105
+ tx_obj = store.transactions.get(self.charger_id)
106
+
107
+ readings = []
108
+ start_updated = False
109
+ temperature = None
110
+ temp_unit = ""
111
+ for mv in payload.get("meterValue", []):
112
+ ts = parse_datetime(mv.get("timestamp"))
113
+ for sv in mv.get("sampledValue", []):
114
+ try:
115
+ val = Decimal(str(sv.get("value")))
116
+ except Exception:
117
+ continue
118
+ if (
119
+ tx_obj
120
+ and tx_obj.meter_start is None
121
+ and sv.get("measurand", "") in ("", "Energy.Active.Import.Register")
122
+ ):
123
+ try:
124
+ mult = 1000 if sv.get("unit") == "kW" else 1
125
+ tx_obj.meter_start = int(val * mult)
126
+ start_updated = True
127
+ except Exception:
128
+ pass
129
+ measurand = sv.get("measurand", "")
130
+ unit = sv.get("unit", "")
131
+ if measurand == "Temperature":
132
+ temperature = val
133
+ temp_unit = unit
134
+ readings.append(
135
+ MeterReading(
136
+ charger=self.charger,
137
+ connector_id=connector,
138
+ transaction=tx_obj,
139
+ timestamp=ts,
140
+ measurand=measurand,
141
+ value=val,
142
+ unit=unit,
143
+ )
144
+ )
145
+ if readings:
146
+ await database_sync_to_async(MeterReading.objects.bulk_create)(readings)
147
+ if tx_obj and start_updated:
148
+ await database_sync_to_async(tx_obj.save)(update_fields=["meter_start"])
149
+ if connector is not None and not self.charger.connector_id:
150
+ self.charger.connector_id = str(connector)
151
+ await database_sync_to_async(self.charger.save)(update_fields=["connector_id"])
152
+ if temperature is not None:
153
+ self.charger.temperature = temperature
154
+ self.charger.temperature_unit = temp_unit
155
+ await database_sync_to_async(self.charger.save)(
156
+ update_fields=["temperature", "temperature_unit"]
157
+ )
158
+
159
+ async def disconnect(self, close_code):
160
+ store.connections.pop(self.charger_id, None)
161
+ store.end_session_log(self.charger_id)
162
+ store.add_log(
163
+ self.charger_id, f"Closed (code={close_code})", log_type="charger"
164
+ )
165
+
166
+ async def receive(self, text_data=None, bytes_data=None):
167
+ raw = text_data
168
+ if raw is None and bytes_data is not None:
169
+ raw = base64.b64encode(bytes_data).decode("ascii")
170
+ if raw is None:
171
+ return
172
+ store.add_log(self.charger_id, raw, log_type="charger")
173
+ store.add_session_message(self.charger_id, raw)
174
+ try:
175
+ msg = json.loads(raw)
176
+ except json.JSONDecodeError:
177
+ return
178
+ if isinstance(msg, list) and msg and msg[0] == 2:
179
+ msg_id, action = msg[1], msg[2]
180
+ payload = msg[3] if len(msg) > 3 else {}
181
+ reply_payload = {}
182
+ if action == "BootNotification":
183
+ reply_payload = {
184
+ "currentTime": datetime.utcnow().isoformat() + "Z",
185
+ "interval": 300,
186
+ "status": "Accepted",
187
+ }
188
+ elif action == "Heartbeat":
189
+ reply_payload = {
190
+ "currentTime": datetime.utcnow().isoformat() + "Z"
191
+ }
192
+ now = timezone.now()
193
+ self.charger.last_heartbeat = now
194
+ await database_sync_to_async(
195
+ Charger.objects.filter(charger_id=self.charger_id).update
196
+ )(last_heartbeat=now)
197
+ elif action == "Authorize":
198
+ account = await self._get_account(payload.get("idTag"))
199
+ if self.charger.require_rfid:
200
+ status = (
201
+ "Accepted"
202
+ if account and await database_sync_to_async(account.can_authorize)()
203
+ else "Invalid"
204
+ )
205
+ else:
206
+ status = "Accepted"
207
+ reply_payload = {"idTagInfo": {"status": status}}
208
+ elif action == "MeterValues":
209
+ await self._store_meter_values(payload, text_data)
210
+ self.charger.last_meter_values = payload
211
+ await database_sync_to_async(
212
+ Charger.objects.filter(charger_id=self.charger_id).update
213
+ )(last_meter_values=payload)
214
+ reply_payload = {}
215
+ elif action == "StartTransaction":
216
+ account = await self._get_account(payload.get("idTag"))
217
+ if self.charger.require_rfid:
218
+ authorized = (
219
+ account is not None
220
+ and await database_sync_to_async(account.can_authorize)()
221
+ )
222
+ else:
223
+ authorized = True
224
+ if authorized:
225
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
226
+ charger=self.charger,
227
+ account=account,
228
+ rfid=(payload.get("idTag") or ""),
229
+ vin=(payload.get("vin") or ""),
230
+ meter_start=payload.get("meterStart"),
231
+ start_time=timezone.now(),
232
+ )
233
+ store.transactions[self.charger_id] = tx_obj
234
+ store.start_session_log(self.charger_id, tx_obj.pk)
235
+ store.add_session_message(self.charger_id, text_data)
236
+ reply_payload = {
237
+ "transactionId": tx_obj.pk,
238
+ "idTagInfo": {"status": "Accepted"},
239
+ }
240
+ else:
241
+ reply_payload = {"idTagInfo": {"status": "Invalid"}}
242
+ elif action == "StopTransaction":
243
+ tx_id = payload.get("transactionId")
244
+ tx_obj = store.transactions.pop(self.charger_id, None)
245
+ if not tx_obj and tx_id is not None:
246
+ tx_obj = await database_sync_to_async(
247
+ Transaction.objects.filter(pk=tx_id, charger=self.charger).first
248
+ )()
249
+ if not tx_obj and tx_id is not None:
250
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
251
+ pk=tx_id,
252
+ charger=self.charger,
253
+ start_time=timezone.now(),
254
+ meter_start=payload.get("meterStart") or payload.get("meterStop"),
255
+ vin=(payload.get("vin") or ""),
256
+ )
257
+ if tx_obj:
258
+ tx_obj.meter_stop = payload.get("meterStop")
259
+ tx_obj.stop_time = timezone.now()
260
+ await database_sync_to_async(tx_obj.save)()
261
+ reply_payload = {"idTagInfo": {"status": "Accepted"}}
262
+ store.end_session_log(self.charger_id)
263
+ response = [3, msg_id, reply_payload]
264
+ await self.send(json.dumps(response))
265
+ store.add_log(
266
+ self.charger_id, f"< {json.dumps(response)}", log_type="charger"
267
+ )