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.
- arthexis-0.1.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- 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
|
+
)
|