arthexis 0.1.9__py3-none-any.whl → 0.1.26__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.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/views.py
CHANGED
|
@@ -1,912 +1,2024 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
4
|
-
from types import SimpleNamespace
|
|
5
|
-
|
|
6
|
-
from django.http import
|
|
7
|
-
from django.http.request import split_domain_port
|
|
8
|
-
from django.views.decorators.csrf import csrf_exempt
|
|
9
|
-
from django.shortcuts import render,
|
|
10
|
-
from django.
|
|
11
|
-
from django.
|
|
12
|
-
from django.
|
|
13
|
-
from django.
|
|
14
|
-
from django.
|
|
15
|
-
from django.utils import
|
|
16
|
-
|
|
17
|
-
from
|
|
18
|
-
|
|
19
|
-
from
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
from .
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
charger.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
if
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
"
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
"
|
|
663
|
-
"
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
)
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, time, timedelta, timezone as dt_timezone
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
7
|
+
from django.http.request import split_domain_port
|
|
8
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
9
|
+
from django.shortcuts import get_object_or_404, render, resolve_url
|
|
10
|
+
from django.template.loader import render_to_string
|
|
11
|
+
from django.core.paginator import Paginator
|
|
12
|
+
from django.contrib.auth.decorators import login_required
|
|
13
|
+
from django.contrib.auth.views import redirect_to_login
|
|
14
|
+
from django.utils.translation import gettext_lazy as _, gettext, ngettext
|
|
15
|
+
from django.utils.text import slugify
|
|
16
|
+
from django.urls import NoReverseMatch, reverse
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from django.utils import translation, timezone, formats
|
|
19
|
+
from django.core.exceptions import ValidationError
|
|
20
|
+
|
|
21
|
+
from asgiref.sync import async_to_sync
|
|
22
|
+
|
|
23
|
+
from utils.api import api_login_required
|
|
24
|
+
|
|
25
|
+
from nodes.models import Node
|
|
26
|
+
|
|
27
|
+
from pages.utils import landing
|
|
28
|
+
from core.liveupdate import live_update
|
|
29
|
+
|
|
30
|
+
from django.utils.dateparse import parse_datetime
|
|
31
|
+
|
|
32
|
+
from . import store
|
|
33
|
+
from .models import Transaction, Charger, DataTransferMessage, RFID
|
|
34
|
+
from .evcs import (
|
|
35
|
+
_start_simulator,
|
|
36
|
+
_stop_simulator,
|
|
37
|
+
get_simulator_state,
|
|
38
|
+
_simulator_status_json,
|
|
39
|
+
)
|
|
40
|
+
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
CALL_ACTION_LABELS = {
|
|
44
|
+
"RemoteStartTransaction": _("Remote start transaction"),
|
|
45
|
+
"RemoteStopTransaction": _("Remote stop transaction"),
|
|
46
|
+
"ChangeAvailability": _("Change availability"),
|
|
47
|
+
"DataTransfer": _("Data transfer"),
|
|
48
|
+
"Reset": _("Reset"),
|
|
49
|
+
"TriggerMessage": _("Trigger message"),
|
|
50
|
+
"ReserveNow": _("Reserve connector"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
|
|
54
|
+
"RemoteStartTransaction": {"Accepted"},
|
|
55
|
+
"RemoteStopTransaction": {"Accepted"},
|
|
56
|
+
"ChangeAvailability": {"Accepted", "Scheduled"},
|
|
57
|
+
"DataTransfer": {"Accepted"},
|
|
58
|
+
"Reset": {"Accepted"},
|
|
59
|
+
"TriggerMessage": {"Accepted"},
|
|
60
|
+
"ReserveNow": {"Accepted"},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_details(value: object) -> str:
|
|
65
|
+
"""Return a JSON representation of ``value`` suitable for error messages."""
|
|
66
|
+
|
|
67
|
+
if value in (None, ""):
|
|
68
|
+
return ""
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
text = value.strip()
|
|
71
|
+
if text:
|
|
72
|
+
return text
|
|
73
|
+
return ""
|
|
74
|
+
try:
|
|
75
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
76
|
+
except TypeError:
|
|
77
|
+
return str(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _evaluate_pending_call_result(
|
|
81
|
+
message_id: str,
|
|
82
|
+
ocpp_action: str,
|
|
83
|
+
*,
|
|
84
|
+
expected_statuses: set[str] | None = None,
|
|
85
|
+
) -> tuple[bool, str | None, int | None]:
|
|
86
|
+
"""Wait for a pending call result and translate failures into messages."""
|
|
87
|
+
|
|
88
|
+
action_label = CALL_ACTION_LABELS.get(ocpp_action, ocpp_action)
|
|
89
|
+
result = store.wait_for_pending_call(message_id, timeout=5.0)
|
|
90
|
+
if result is None:
|
|
91
|
+
detail = _("%(action)s did not receive a response from the charger.") % {
|
|
92
|
+
"action": action_label,
|
|
93
|
+
}
|
|
94
|
+
return False, detail, 504
|
|
95
|
+
if not result.get("success", True):
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
error_code = str(result.get("error_code") or "").strip()
|
|
98
|
+
if error_code:
|
|
99
|
+
parts.append(_("code=%(code)s") % {"code": error_code})
|
|
100
|
+
error_description = str(result.get("error_description") or "").strip()
|
|
101
|
+
if error_description:
|
|
102
|
+
parts.append(
|
|
103
|
+
_("description=%(description)s") % {"description": error_description}
|
|
104
|
+
)
|
|
105
|
+
error_details = result.get("error_details")
|
|
106
|
+
details_text = _format_details(error_details)
|
|
107
|
+
if details_text:
|
|
108
|
+
parts.append(_("details=%(details)s") % {"details": details_text})
|
|
109
|
+
if parts:
|
|
110
|
+
detail = _("%(action)s failed: %(details)s") % {
|
|
111
|
+
"action": action_label,
|
|
112
|
+
"details": ", ".join(parts),
|
|
113
|
+
}
|
|
114
|
+
else:
|
|
115
|
+
detail = _("%(action)s failed.") % {"action": action_label}
|
|
116
|
+
return False, detail, 400
|
|
117
|
+
payload = result.get("payload")
|
|
118
|
+
payload_dict = payload if isinstance(payload, dict) else {}
|
|
119
|
+
if expected_statuses is not None:
|
|
120
|
+
status_value = str(payload_dict.get("status") or "").strip()
|
|
121
|
+
normalized_expected = {value.casefold() for value in expected_statuses if value}
|
|
122
|
+
if not status_value:
|
|
123
|
+
detail = _("%(action)s response did not include a status.") % {
|
|
124
|
+
"action": action_label,
|
|
125
|
+
}
|
|
126
|
+
return False, detail, 400
|
|
127
|
+
if normalized_expected and status_value.casefold() not in normalized_expected:
|
|
128
|
+
detail = _("%(action)s rejected with status %(status)s.") % {
|
|
129
|
+
"action": action_label,
|
|
130
|
+
"status": status_value,
|
|
131
|
+
}
|
|
132
|
+
remaining = {k: v for k, v in payload_dict.items() if k != "status"}
|
|
133
|
+
extra = _format_details(remaining)
|
|
134
|
+
if extra:
|
|
135
|
+
detail += " " + _("Details: %(details)s") % {"details": extra}
|
|
136
|
+
return False, detail, 400
|
|
137
|
+
return True, None, None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
|
|
141
|
+
"""Return connector value and normalized slug or raise 404."""
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
value = Charger.connector_value_from_slug(slug)
|
|
145
|
+
except ValueError as exc: # pragma: no cover - defensive guard
|
|
146
|
+
raise Http404("Invalid connector") from exc
|
|
147
|
+
return value, Charger.connector_slug_from_value(value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
|
|
151
|
+
"""Return URL name for connector-aware routes."""
|
|
152
|
+
|
|
153
|
+
target = f"{name}-connector"
|
|
154
|
+
if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
|
|
155
|
+
try:
|
|
156
|
+
return reverse(target, args=[serial, connector_slug])
|
|
157
|
+
except NoReverseMatch:
|
|
158
|
+
return reverse(name, args=[serial])
|
|
159
|
+
return reverse(target, args=[serial, connector_slug])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
|
|
163
|
+
"""Return charger for the requested identity, creating if necessary."""
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
serial = Charger.validate_serial(serial)
|
|
167
|
+
except ValidationError as exc:
|
|
168
|
+
raise Http404("Charger not found") from exc
|
|
169
|
+
connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
|
|
170
|
+
if connector_value is None:
|
|
171
|
+
charger, _ = Charger.objects.get_or_create(
|
|
172
|
+
charger_id=serial,
|
|
173
|
+
connector_id=None,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
charger, _ = Charger.objects.get_or_create(
|
|
177
|
+
charger_id=serial,
|
|
178
|
+
connector_id=connector_value,
|
|
179
|
+
)
|
|
180
|
+
return charger, normalized_slug
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _connector_set(charger: Charger) -> list[Charger]:
|
|
184
|
+
"""Return chargers sharing the same serial ordered for navigation."""
|
|
185
|
+
|
|
186
|
+
siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
|
|
187
|
+
siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
|
|
188
|
+
return siblings
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _visible_chargers(user):
|
|
192
|
+
"""Return chargers visible to ``user`` on public dashboards."""
|
|
193
|
+
|
|
194
|
+
return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _ensure_charger_access(
|
|
198
|
+
user,
|
|
199
|
+
charger: Charger,
|
|
200
|
+
*,
|
|
201
|
+
request=None,
|
|
202
|
+
) -> HttpResponse | None:
|
|
203
|
+
"""Ensure ``user`` may view ``charger``.
|
|
204
|
+
|
|
205
|
+
Returns a redirect to the login page when authentication is required,
|
|
206
|
+
otherwise raises :class:`~django.http.Http404` if the charger should not be
|
|
207
|
+
visible to the user.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
if charger.is_visible_to(user):
|
|
211
|
+
return None
|
|
212
|
+
if (
|
|
213
|
+
request is not None
|
|
214
|
+
and not getattr(user, "is_authenticated", False)
|
|
215
|
+
and charger.has_owner_scope()
|
|
216
|
+
):
|
|
217
|
+
return redirect_to_login(
|
|
218
|
+
request.get_full_path(),
|
|
219
|
+
login_url=resolve_url(settings.LOGIN_URL),
|
|
220
|
+
)
|
|
221
|
+
raise Http404("Charger not found")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _transaction_rfid_details(
|
|
225
|
+
tx_obj, *, cache: dict[str, dict[str, str | None]] | None = None
|
|
226
|
+
) -> dict[str, str | None] | None:
|
|
227
|
+
"""Return normalized RFID metadata for a transaction-like object."""
|
|
228
|
+
|
|
229
|
+
if not tx_obj:
|
|
230
|
+
return None
|
|
231
|
+
rfid_value = getattr(tx_obj, "rfid", None)
|
|
232
|
+
normalized = str(rfid_value or "").strip().upper()
|
|
233
|
+
cache_key = normalized
|
|
234
|
+
if normalized:
|
|
235
|
+
if cache is not None and cache_key in cache:
|
|
236
|
+
return cache[cache_key]
|
|
237
|
+
tag = (
|
|
238
|
+
RFID.matching_queryset(normalized)
|
|
239
|
+
.only("pk", "label_id", "custom_label")
|
|
240
|
+
.first()
|
|
241
|
+
)
|
|
242
|
+
rfid_url = None
|
|
243
|
+
label_value = None
|
|
244
|
+
canonical_value = normalized
|
|
245
|
+
if tag:
|
|
246
|
+
try:
|
|
247
|
+
rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
|
|
248
|
+
except NoReverseMatch: # pragma: no cover - admin may be disabled
|
|
249
|
+
rfid_url = None
|
|
250
|
+
custom_label = (tag.custom_label or "").strip()
|
|
251
|
+
if custom_label:
|
|
252
|
+
label_value = custom_label
|
|
253
|
+
elif tag.label_id is not None:
|
|
254
|
+
label_value = str(tag.label_id)
|
|
255
|
+
canonical_value = tag.rfid or canonical_value
|
|
256
|
+
display_value = label_value or canonical_value
|
|
257
|
+
details = {
|
|
258
|
+
"value": display_value,
|
|
259
|
+
"url": rfid_url,
|
|
260
|
+
"uid": canonical_value,
|
|
261
|
+
"type": "rfid",
|
|
262
|
+
"display_label": gettext("RFID"),
|
|
263
|
+
}
|
|
264
|
+
if label_value:
|
|
265
|
+
details["label"] = label_value
|
|
266
|
+
if cache is not None:
|
|
267
|
+
cache[cache_key] = details
|
|
268
|
+
return details
|
|
269
|
+
|
|
270
|
+
identifier_value = getattr(tx_obj, "vehicle_identifier", None)
|
|
271
|
+
normalized_identifier = str(identifier_value or "").strip()
|
|
272
|
+
if not normalized_identifier:
|
|
273
|
+
vid_value = getattr(tx_obj, "vid", None)
|
|
274
|
+
vin_value = getattr(tx_obj, "vin", None)
|
|
275
|
+
normalized_identifier = str(vid_value or vin_value or "").strip()
|
|
276
|
+
if not normalized_identifier:
|
|
277
|
+
return None
|
|
278
|
+
source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
|
|
279
|
+
if source not in {"vid", "vin"}:
|
|
280
|
+
vid_raw = getattr(tx_obj, "vid", None)
|
|
281
|
+
vin_raw = getattr(tx_obj, "vin", None)
|
|
282
|
+
if str(vid_raw or "").strip():
|
|
283
|
+
source = "vid"
|
|
284
|
+
elif str(vin_raw or "").strip():
|
|
285
|
+
source = "vin"
|
|
286
|
+
else:
|
|
287
|
+
source = "vid"
|
|
288
|
+
cache_key = f"{source}:{normalized_identifier}"
|
|
289
|
+
if cache is not None and cache_key in cache:
|
|
290
|
+
return cache[cache_key]
|
|
291
|
+
label = gettext("VID") if source == "vid" else gettext("VIN")
|
|
292
|
+
details = {
|
|
293
|
+
"value": normalized_identifier,
|
|
294
|
+
"url": None,
|
|
295
|
+
"uid": None,
|
|
296
|
+
"type": source,
|
|
297
|
+
"display_label": label,
|
|
298
|
+
}
|
|
299
|
+
if cache is not None:
|
|
300
|
+
cache[cache_key] = details
|
|
301
|
+
return details
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _connector_overview(
|
|
305
|
+
charger: Charger,
|
|
306
|
+
user=None,
|
|
307
|
+
*,
|
|
308
|
+
rfid_cache: dict[str, dict[str, str | None]] | None = None,
|
|
309
|
+
) -> list[dict]:
|
|
310
|
+
"""Return connector metadata used for navigation and summaries."""
|
|
311
|
+
|
|
312
|
+
overview: list[dict] = []
|
|
313
|
+
for sibling in _connector_set(charger):
|
|
314
|
+
if user is not None and not sibling.is_visible_to(user):
|
|
315
|
+
continue
|
|
316
|
+
tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
|
|
317
|
+
state, color = _charger_state(sibling, tx_obj)
|
|
318
|
+
overview.append(
|
|
319
|
+
{
|
|
320
|
+
"charger": sibling,
|
|
321
|
+
"slug": sibling.connector_slug,
|
|
322
|
+
"label": sibling.connector_label,
|
|
323
|
+
"url": _reverse_connector_url(
|
|
324
|
+
"charger-page", sibling.charger_id, sibling.connector_slug
|
|
325
|
+
),
|
|
326
|
+
"status": state,
|
|
327
|
+
"color": color,
|
|
328
|
+
"last_status": sibling.last_status,
|
|
329
|
+
"last_error_code": sibling.last_error_code,
|
|
330
|
+
"last_status_timestamp": sibling.last_status_timestamp,
|
|
331
|
+
"last_status_vendor_info": sibling.last_status_vendor_info,
|
|
332
|
+
"tx": tx_obj,
|
|
333
|
+
"rfid_details": _transaction_rfid_details(
|
|
334
|
+
tx_obj, cache=rfid_cache
|
|
335
|
+
),
|
|
336
|
+
"connected": store.is_connected(
|
|
337
|
+
sibling.charger_id, sibling.connector_id
|
|
338
|
+
),
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
return overview
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _normalize_timeline_status(value: str | None) -> str | None:
|
|
345
|
+
"""Normalize raw charger status strings into timeline buckets."""
|
|
346
|
+
|
|
347
|
+
normalized = (value or "").strip().lower()
|
|
348
|
+
if not normalized:
|
|
349
|
+
return None
|
|
350
|
+
charging_states = {
|
|
351
|
+
"charging",
|
|
352
|
+
"finishing",
|
|
353
|
+
"suspendedev",
|
|
354
|
+
"suspendedevse",
|
|
355
|
+
"occupied",
|
|
356
|
+
}
|
|
357
|
+
available_states = {"available", "preparing", "reserved"}
|
|
358
|
+
offline_states = {"faulted", "unavailable", "outofservice"}
|
|
359
|
+
if normalized in charging_states:
|
|
360
|
+
return "charging"
|
|
361
|
+
if normalized in offline_states:
|
|
362
|
+
return "offline"
|
|
363
|
+
if normalized in available_states:
|
|
364
|
+
return "available"
|
|
365
|
+
# Treat other states as available for the initial implementation.
|
|
366
|
+
return "available"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _timeline_labels() -> dict[str, str]:
|
|
370
|
+
"""Return translated labels for timeline statuses."""
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"offline": gettext("Offline"),
|
|
374
|
+
"available": gettext("Available"),
|
|
375
|
+
"charging": gettext("Charging"),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
|
|
380
|
+
"""Return localized display values for a timeline range."""
|
|
381
|
+
|
|
382
|
+
start_display = formats.date_format(
|
|
383
|
+
timezone.localtime(start), "SHORT_DATETIME_FORMAT"
|
|
384
|
+
)
|
|
385
|
+
end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
|
|
386
|
+
return start_display, end_display
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _collect_status_events(
|
|
390
|
+
charger: Charger,
|
|
391
|
+
connector: Charger,
|
|
392
|
+
window_start: datetime,
|
|
393
|
+
window_end: datetime,
|
|
394
|
+
) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
|
|
395
|
+
"""Parse log entries into ordered status events for the connector."""
|
|
396
|
+
|
|
397
|
+
connector_id = connector.connector_id
|
|
398
|
+
serial = connector.charger_id
|
|
399
|
+
keys = [store.identity_key(serial, connector_id)]
|
|
400
|
+
if connector_id is not None:
|
|
401
|
+
keys.append(store.identity_key(serial, None))
|
|
402
|
+
keys.append(store.pending_key(serial))
|
|
403
|
+
|
|
404
|
+
seen_entries: set[str] = set()
|
|
405
|
+
events: list[tuple[datetime, str]] = []
|
|
406
|
+
latest_before_window: tuple[datetime, str] | None = None
|
|
407
|
+
|
|
408
|
+
for key in keys:
|
|
409
|
+
for entry in store.get_logs(key, log_type="charger"):
|
|
410
|
+
if entry in seen_entries:
|
|
411
|
+
continue
|
|
412
|
+
seen_entries.add(entry)
|
|
413
|
+
if len(entry) < 24:
|
|
414
|
+
continue
|
|
415
|
+
timestamp_raw = entry[:23]
|
|
416
|
+
message = entry[24:].strip()
|
|
417
|
+
try:
|
|
418
|
+
log_timestamp = datetime.strptime(
|
|
419
|
+
timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
|
|
420
|
+
).replace(tzinfo=dt_timezone.utc)
|
|
421
|
+
except ValueError:
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
event_time = log_timestamp
|
|
425
|
+
status_bucket: str | None = None
|
|
426
|
+
|
|
427
|
+
if message.startswith("StatusNotification processed:"):
|
|
428
|
+
payload_text = message.split(":", 1)[1].strip()
|
|
429
|
+
try:
|
|
430
|
+
payload = json.loads(payload_text)
|
|
431
|
+
except json.JSONDecodeError:
|
|
432
|
+
continue
|
|
433
|
+
target_id = payload.get("connectorId")
|
|
434
|
+
if connector_id is not None:
|
|
435
|
+
try:
|
|
436
|
+
normalized_target = int(target_id)
|
|
437
|
+
except (TypeError, ValueError):
|
|
438
|
+
normalized_target = None
|
|
439
|
+
if normalized_target not in {connector_id, None}:
|
|
440
|
+
continue
|
|
441
|
+
raw_status = payload.get("status")
|
|
442
|
+
status_bucket = _normalize_timeline_status(
|
|
443
|
+
raw_status if isinstance(raw_status, str) else None
|
|
444
|
+
)
|
|
445
|
+
payload_timestamp = payload.get("timestamp")
|
|
446
|
+
if isinstance(payload_timestamp, str):
|
|
447
|
+
parsed = parse_datetime(payload_timestamp)
|
|
448
|
+
if parsed is not None:
|
|
449
|
+
if timezone.is_naive(parsed):
|
|
450
|
+
parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
|
|
451
|
+
event_time = parsed
|
|
452
|
+
elif message.startswith("Connected"):
|
|
453
|
+
status_bucket = "available"
|
|
454
|
+
elif message.startswith("Closed"):
|
|
455
|
+
status_bucket = "offline"
|
|
456
|
+
|
|
457
|
+
if not status_bucket:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if event_time < window_start:
|
|
461
|
+
if (
|
|
462
|
+
latest_before_window is None
|
|
463
|
+
or event_time > latest_before_window[0]
|
|
464
|
+
):
|
|
465
|
+
latest_before_window = (event_time, status_bucket)
|
|
466
|
+
continue
|
|
467
|
+
if event_time > window_end:
|
|
468
|
+
continue
|
|
469
|
+
events.append((event_time, status_bucket))
|
|
470
|
+
|
|
471
|
+
events.sort(key=lambda item: item[0])
|
|
472
|
+
|
|
473
|
+
deduped_events: list[tuple[datetime, str]] = []
|
|
474
|
+
for event_time, state in events:
|
|
475
|
+
if deduped_events and deduped_events[-1][1] == state:
|
|
476
|
+
continue
|
|
477
|
+
deduped_events.append((event_time, state))
|
|
478
|
+
|
|
479
|
+
return deduped_events, latest_before_window
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _usage_timeline(
|
|
483
|
+
charger: Charger,
|
|
484
|
+
connector_overview: list[dict],
|
|
485
|
+
*,
|
|
486
|
+
now: datetime | None = None,
|
|
487
|
+
) -> tuple[list[dict], tuple[str, str] | None]:
|
|
488
|
+
"""Build usage timeline data for inactive chargers."""
|
|
489
|
+
|
|
490
|
+
if now is None:
|
|
491
|
+
now = timezone.now()
|
|
492
|
+
window_end = now
|
|
493
|
+
window_start = now - timedelta(days=7)
|
|
494
|
+
|
|
495
|
+
if charger.connector_id is not None:
|
|
496
|
+
connectors = [charger]
|
|
497
|
+
else:
|
|
498
|
+
connectors = [
|
|
499
|
+
item["charger"]
|
|
500
|
+
for item in connector_overview
|
|
501
|
+
if item.get("charger") and item["charger"].connector_id is not None
|
|
502
|
+
]
|
|
503
|
+
if not connectors:
|
|
504
|
+
connectors = [
|
|
505
|
+
sibling
|
|
506
|
+
for sibling in _connector_set(charger)
|
|
507
|
+
if sibling.connector_id is not None
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
seen_ids: set[int] = set()
|
|
511
|
+
labels = _timeline_labels()
|
|
512
|
+
timeline_entries: list[dict] = []
|
|
513
|
+
window_display: tuple[str, str] | None = None
|
|
514
|
+
|
|
515
|
+
if window_start < window_end:
|
|
516
|
+
window_display = _format_segment_range(window_start, window_end)
|
|
517
|
+
|
|
518
|
+
for connector in connectors:
|
|
519
|
+
if connector.connector_id is None:
|
|
520
|
+
continue
|
|
521
|
+
if connector.connector_id in seen_ids:
|
|
522
|
+
continue
|
|
523
|
+
seen_ids.add(connector.connector_id)
|
|
524
|
+
|
|
525
|
+
events, prior_event = _collect_status_events(
|
|
526
|
+
charger, connector, window_start, window_end
|
|
527
|
+
)
|
|
528
|
+
fallback_state = _normalize_timeline_status(connector.last_status)
|
|
529
|
+
if fallback_state is None:
|
|
530
|
+
fallback_state = (
|
|
531
|
+
"available"
|
|
532
|
+
if store.is_connected(connector.charger_id, connector.connector_id)
|
|
533
|
+
else "offline"
|
|
534
|
+
)
|
|
535
|
+
current_state = fallback_state
|
|
536
|
+
if prior_event is not None:
|
|
537
|
+
current_state = prior_event[1]
|
|
538
|
+
segments: list[dict] = []
|
|
539
|
+
previous_time = window_start
|
|
540
|
+
total_seconds = (window_end - window_start).total_seconds()
|
|
541
|
+
|
|
542
|
+
for event_time, state in events:
|
|
543
|
+
if event_time <= window_start:
|
|
544
|
+
current_state = state
|
|
545
|
+
continue
|
|
546
|
+
if event_time > window_end:
|
|
547
|
+
break
|
|
548
|
+
if state == current_state:
|
|
549
|
+
continue
|
|
550
|
+
segment_start = max(previous_time, window_start)
|
|
551
|
+
segment_end = min(event_time, window_end)
|
|
552
|
+
if segment_end > segment_start:
|
|
553
|
+
duration = (segment_end - segment_start).total_seconds()
|
|
554
|
+
start_display, end_display = _format_segment_range(
|
|
555
|
+
segment_start, segment_end
|
|
556
|
+
)
|
|
557
|
+
segments.append(
|
|
558
|
+
{
|
|
559
|
+
"status": current_state,
|
|
560
|
+
"label": labels.get(current_state, current_state.title()),
|
|
561
|
+
"start_display": start_display,
|
|
562
|
+
"end_display": end_display,
|
|
563
|
+
"duration": max(duration, 1.0),
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
current_state = state
|
|
567
|
+
previous_time = max(event_time, window_start)
|
|
568
|
+
|
|
569
|
+
if previous_time < window_end:
|
|
570
|
+
segment_start = max(previous_time, window_start)
|
|
571
|
+
segment_end = window_end
|
|
572
|
+
if segment_end > segment_start:
|
|
573
|
+
duration = (segment_end - segment_start).total_seconds()
|
|
574
|
+
start_display, end_display = _format_segment_range(
|
|
575
|
+
segment_start, segment_end
|
|
576
|
+
)
|
|
577
|
+
segments.append(
|
|
578
|
+
{
|
|
579
|
+
"status": current_state,
|
|
580
|
+
"label": labels.get(current_state, current_state.title()),
|
|
581
|
+
"start_display": start_display,
|
|
582
|
+
"end_display": end_display,
|
|
583
|
+
"duration": max(duration, 1.0),
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if not segments and total_seconds > 0:
|
|
588
|
+
start_display, end_display = _format_segment_range(window_start, window_end)
|
|
589
|
+
segments.append(
|
|
590
|
+
{
|
|
591
|
+
"status": current_state,
|
|
592
|
+
"label": labels.get(current_state, current_state.title()),
|
|
593
|
+
"start_display": start_display,
|
|
594
|
+
"end_display": end_display,
|
|
595
|
+
"duration": max(total_seconds, 1.0),
|
|
596
|
+
}
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
if segments:
|
|
600
|
+
timeline_entries.append(
|
|
601
|
+
{
|
|
602
|
+
"label": connector.connector_label,
|
|
603
|
+
"segments": segments,
|
|
604
|
+
}
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
return timeline_entries, window_display
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
|
|
611
|
+
"""Return active sessions grouped by connector for the charger."""
|
|
612
|
+
|
|
613
|
+
siblings = _connector_set(charger)
|
|
614
|
+
ordered = [c for c in siblings if c.connector_id is not None] + [
|
|
615
|
+
c for c in siblings if c.connector_id is None
|
|
616
|
+
]
|
|
617
|
+
sessions: list[tuple[Charger, Transaction]] = []
|
|
618
|
+
seen: set[int] = set()
|
|
619
|
+
for sibling in ordered:
|
|
620
|
+
tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
|
|
621
|
+
if not tx_obj:
|
|
622
|
+
continue
|
|
623
|
+
if tx_obj.pk and tx_obj.pk in seen:
|
|
624
|
+
continue
|
|
625
|
+
if tx_obj.pk:
|
|
626
|
+
seen.add(tx_obj.pk)
|
|
627
|
+
sessions.append((sibling, tx_obj))
|
|
628
|
+
return sessions
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _landing_page_translations() -> dict[str, dict[str, str]]:
|
|
632
|
+
"""Return static translations used by the charger public landing page."""
|
|
633
|
+
|
|
634
|
+
catalog: dict[str, dict[str, str]] = {}
|
|
635
|
+
seen_codes: set[str] = set()
|
|
636
|
+
for code, _name in settings.LANGUAGES:
|
|
637
|
+
normalized = str(code).strip()
|
|
638
|
+
if not normalized or normalized in seen_codes:
|
|
639
|
+
continue
|
|
640
|
+
seen_codes.add(normalized)
|
|
641
|
+
with translation.override(normalized):
|
|
642
|
+
catalog[normalized] = {
|
|
643
|
+
"serial_number_label": gettext("Serial Number"),
|
|
644
|
+
"connector_label": gettext("Connector"),
|
|
645
|
+
"advanced_view_label": gettext("Advanced View"),
|
|
646
|
+
"require_rfid_label": gettext("Require RFID Authorization"),
|
|
647
|
+
"charging_label": gettext("Charging"),
|
|
648
|
+
"energy_label": gettext("Energy"),
|
|
649
|
+
"started_label": gettext("Started"),
|
|
650
|
+
"rfid_label": gettext("RFID"),
|
|
651
|
+
"instruction_text": gettext(
|
|
652
|
+
"Plug in your vehicle and slide your RFID card over the reader to begin charging."
|
|
653
|
+
),
|
|
654
|
+
"connectors_heading": gettext("Connectors"),
|
|
655
|
+
"no_active_transaction": gettext("No active transaction"),
|
|
656
|
+
"connectors_active_singular": ngettext(
|
|
657
|
+
"%(count)s connector active",
|
|
658
|
+
"%(count)s connectors active",
|
|
659
|
+
1,
|
|
660
|
+
),
|
|
661
|
+
"connectors_active_plural": ngettext(
|
|
662
|
+
"%(count)s connector active",
|
|
663
|
+
"%(count)s connectors active",
|
|
664
|
+
2,
|
|
665
|
+
),
|
|
666
|
+
"status_reported_label": gettext("Reported status"),
|
|
667
|
+
"status_error_label": gettext("Error code"),
|
|
668
|
+
"status_updated_label": gettext("Last status update"),
|
|
669
|
+
"status_vendor_label": gettext("Vendor"),
|
|
670
|
+
"status_info_label": gettext("Info"),
|
|
671
|
+
}
|
|
672
|
+
return catalog
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _has_active_session(tx_obj) -> bool:
|
|
676
|
+
"""Return whether the provided transaction-like object is active."""
|
|
677
|
+
|
|
678
|
+
if isinstance(tx_obj, (list, tuple, set)):
|
|
679
|
+
return any(_has_active_session(item) for item in tx_obj)
|
|
680
|
+
if not tx_obj:
|
|
681
|
+
return False
|
|
682
|
+
if isinstance(tx_obj, dict):
|
|
683
|
+
return tx_obj.get("stop_time") is None
|
|
684
|
+
stop_time = getattr(tx_obj, "stop_time", None)
|
|
685
|
+
return stop_time is None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
|
|
689
|
+
"""Return an aggregate badge for the charger when summarising connectors."""
|
|
690
|
+
|
|
691
|
+
if charger.connector_id is not None:
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
siblings = (
|
|
695
|
+
Charger.objects.filter(charger_id=charger.charger_id)
|
|
696
|
+
.exclude(pk=charger.pk)
|
|
697
|
+
.exclude(connector_id__isnull=True)
|
|
698
|
+
)
|
|
699
|
+
statuses: list[str] = []
|
|
700
|
+
for sibling in siblings:
|
|
701
|
+
tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
|
|
702
|
+
if not tx_obj:
|
|
703
|
+
tx_obj = (
|
|
704
|
+
Transaction.objects.filter(charger=sibling, stop_time__isnull=True)
|
|
705
|
+
.order_by("-start_time")
|
|
706
|
+
.first()
|
|
707
|
+
)
|
|
708
|
+
has_session = _has_active_session(tx_obj)
|
|
709
|
+
status_value = (sibling.last_status or "").strip()
|
|
710
|
+
normalized_status = status_value.casefold() if status_value else ""
|
|
711
|
+
error_code_lower = (sibling.last_error_code or "").strip().lower()
|
|
712
|
+
if has_session:
|
|
713
|
+
statuses.append("charging")
|
|
714
|
+
continue
|
|
715
|
+
if (
|
|
716
|
+
normalized_status in {"charging", "finishing"}
|
|
717
|
+
and error_code_lower in ERROR_OK_VALUES
|
|
718
|
+
):
|
|
719
|
+
statuses.append("available")
|
|
720
|
+
continue
|
|
721
|
+
if normalized_status:
|
|
722
|
+
statuses.append(normalized_status)
|
|
723
|
+
continue
|
|
724
|
+
if store.is_connected(sibling.charger_id, sibling.connector_id):
|
|
725
|
+
statuses.append("available")
|
|
726
|
+
|
|
727
|
+
if not statuses:
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
if any(status == "available" for status in statuses):
|
|
731
|
+
return STATUS_BADGE_MAP["available"]
|
|
732
|
+
|
|
733
|
+
if all(status == "charging" for status in statuses):
|
|
734
|
+
return STATUS_BADGE_MAP["charging"]
|
|
735
|
+
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
|
|
740
|
+
"""Return human readable state and color for a charger."""
|
|
741
|
+
|
|
742
|
+
status_value = (charger.last_status or "").strip()
|
|
743
|
+
normalized_status = status_value.casefold() if status_value else ""
|
|
744
|
+
|
|
745
|
+
aggregate_state = _aggregate_dashboard_state(charger)
|
|
746
|
+
if aggregate_state is not None and normalized_status in {"", "available", "charging"}:
|
|
747
|
+
return aggregate_state
|
|
748
|
+
|
|
749
|
+
has_session = _has_active_session(tx_obj)
|
|
750
|
+
if status_value:
|
|
751
|
+
key = normalized_status
|
|
752
|
+
label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
|
|
753
|
+
error_code = (charger.last_error_code or "").strip()
|
|
754
|
+
error_code_lower = error_code.lower()
|
|
755
|
+
if (
|
|
756
|
+
has_session
|
|
757
|
+
and error_code_lower in ERROR_OK_VALUES
|
|
758
|
+
and (key not in STATUS_BADGE_MAP or key == "available")
|
|
759
|
+
):
|
|
760
|
+
# Some stations continue reporting "Available" (or an unknown status)
|
|
761
|
+
# while a session is active. Override the badge so the user can see
|
|
762
|
+
# the charger is actually busy.
|
|
763
|
+
label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
|
|
764
|
+
elif (
|
|
765
|
+
not has_session
|
|
766
|
+
and key in {"charging", "finishing"}
|
|
767
|
+
and error_code_lower in ERROR_OK_VALUES
|
|
768
|
+
):
|
|
769
|
+
# Some chargers continue reporting "Charging" after a session ends.
|
|
770
|
+
# When no active transaction exists, surface the state as available
|
|
771
|
+
# so the UI reflects the actual behaviour at the site.
|
|
772
|
+
label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
|
|
773
|
+
elif error_code and error_code_lower not in ERROR_OK_VALUES:
|
|
774
|
+
label = _("%(status)s (%(error)s)") % {
|
|
775
|
+
"status": label,
|
|
776
|
+
"error": error_code,
|
|
777
|
+
}
|
|
778
|
+
color = "#dc3545"
|
|
779
|
+
return label, color
|
|
780
|
+
|
|
781
|
+
cid = charger.charger_id
|
|
782
|
+
connected = store.is_connected(cid, charger.connector_id)
|
|
783
|
+
if connected and has_session:
|
|
784
|
+
return _("Charging"), "green"
|
|
785
|
+
if connected:
|
|
786
|
+
return _("Available"), "blue"
|
|
787
|
+
return _("Offline"), "grey"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
|
|
791
|
+
"""Return diagnostics metadata for API responses."""
|
|
792
|
+
|
|
793
|
+
timestamp = (
|
|
794
|
+
charger.diagnostics_timestamp.isoformat()
|
|
795
|
+
if charger.diagnostics_timestamp
|
|
796
|
+
else None
|
|
797
|
+
)
|
|
798
|
+
status = charger.diagnostics_status or None
|
|
799
|
+
location = charger.diagnostics_location or None
|
|
800
|
+
return {
|
|
801
|
+
"diagnosticsStatus": status,
|
|
802
|
+
"diagnosticsTimestamp": timestamp,
|
|
803
|
+
"diagnosticsLocation": location,
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@api_login_required
|
|
808
|
+
def charger_list(request):
|
|
809
|
+
"""Return a JSON list of known chargers and state."""
|
|
810
|
+
data = []
|
|
811
|
+
for charger in _visible_chargers(request.user):
|
|
812
|
+
cid = charger.charger_id
|
|
813
|
+
sessions: list[tuple[Charger, Transaction]] = []
|
|
814
|
+
tx_obj = store.get_transaction(cid, charger.connector_id)
|
|
815
|
+
if charger.connector_id is None:
|
|
816
|
+
sessions = _live_sessions(charger)
|
|
817
|
+
if sessions:
|
|
818
|
+
tx_obj = sessions[0][1]
|
|
819
|
+
elif tx_obj:
|
|
820
|
+
sessions = [(charger, tx_obj)]
|
|
821
|
+
if not tx_obj:
|
|
822
|
+
tx_obj = (
|
|
823
|
+
Transaction.objects.filter(charger__charger_id=cid)
|
|
824
|
+
.order_by("-start_time")
|
|
825
|
+
.first()
|
|
826
|
+
)
|
|
827
|
+
tx_data = None
|
|
828
|
+
if tx_obj:
|
|
829
|
+
tx_data = {
|
|
830
|
+
"transactionId": tx_obj.pk,
|
|
831
|
+
"meterStart": tx_obj.meter_start,
|
|
832
|
+
"startTime": tx_obj.start_time.isoformat(),
|
|
833
|
+
}
|
|
834
|
+
identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
|
|
835
|
+
if identifier:
|
|
836
|
+
tx_data["vid"] = identifier
|
|
837
|
+
legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
|
|
838
|
+
if legacy_vin:
|
|
839
|
+
tx_data["vin"] = legacy_vin
|
|
840
|
+
if tx_obj.meter_stop is not None:
|
|
841
|
+
tx_data["meterStop"] = tx_obj.meter_stop
|
|
842
|
+
if tx_obj.stop_time is not None:
|
|
843
|
+
tx_data["stopTime"] = tx_obj.stop_time.isoformat()
|
|
844
|
+
active_transactions = []
|
|
845
|
+
for session_charger, session_tx in sessions:
|
|
846
|
+
active_payload = {
|
|
847
|
+
"charger_id": session_charger.charger_id,
|
|
848
|
+
"connector_id": session_charger.connector_id,
|
|
849
|
+
"connector_slug": session_charger.connector_slug,
|
|
850
|
+
"transactionId": session_tx.pk,
|
|
851
|
+
"meterStart": session_tx.meter_start,
|
|
852
|
+
"startTime": session_tx.start_time.isoformat(),
|
|
853
|
+
}
|
|
854
|
+
identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
|
|
855
|
+
if identifier:
|
|
856
|
+
active_payload["vid"] = identifier
|
|
857
|
+
legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
|
|
858
|
+
if legacy_vin:
|
|
859
|
+
active_payload["vin"] = legacy_vin
|
|
860
|
+
if session_tx.meter_stop is not None:
|
|
861
|
+
active_payload["meterStop"] = session_tx.meter_stop
|
|
862
|
+
if session_tx.stop_time is not None:
|
|
863
|
+
active_payload["stopTime"] = session_tx.stop_time.isoformat()
|
|
864
|
+
active_transactions.append(active_payload)
|
|
865
|
+
state, color = _charger_state(
|
|
866
|
+
charger,
|
|
867
|
+
tx_obj if charger.connector_id is not None else (sessions if sessions else None),
|
|
868
|
+
)
|
|
869
|
+
entry = {
|
|
870
|
+
"charger_id": cid,
|
|
871
|
+
"name": charger.name,
|
|
872
|
+
"connector_id": charger.connector_id,
|
|
873
|
+
"connector_slug": charger.connector_slug,
|
|
874
|
+
"connector_label": charger.connector_label,
|
|
875
|
+
"require_rfid": charger.require_rfid,
|
|
876
|
+
"transaction": tx_data,
|
|
877
|
+
"activeTransactions": active_transactions,
|
|
878
|
+
"lastHeartbeat": (
|
|
879
|
+
charger.last_heartbeat.isoformat()
|
|
880
|
+
if charger.last_heartbeat
|
|
881
|
+
else None
|
|
882
|
+
),
|
|
883
|
+
"lastMeterValues": charger.last_meter_values,
|
|
884
|
+
"firmwareStatus": charger.firmware_status,
|
|
885
|
+
"firmwareStatusInfo": charger.firmware_status_info,
|
|
886
|
+
"firmwareTimestamp": (
|
|
887
|
+
charger.firmware_timestamp.isoformat()
|
|
888
|
+
if charger.firmware_timestamp
|
|
889
|
+
else None
|
|
890
|
+
),
|
|
891
|
+
"connected": store.is_connected(cid, charger.connector_id),
|
|
892
|
+
"lastStatus": charger.last_status or None,
|
|
893
|
+
"lastErrorCode": charger.last_error_code or None,
|
|
894
|
+
"lastStatusTimestamp": (
|
|
895
|
+
charger.last_status_timestamp.isoformat()
|
|
896
|
+
if charger.last_status_timestamp
|
|
897
|
+
else None
|
|
898
|
+
),
|
|
899
|
+
"lastStatusVendorInfo": charger.last_status_vendor_info,
|
|
900
|
+
"status": state,
|
|
901
|
+
"statusColor": color,
|
|
902
|
+
}
|
|
903
|
+
entry.update(_diagnostics_payload(charger))
|
|
904
|
+
data.append(entry)
|
|
905
|
+
return JsonResponse({"chargers": data})
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
@api_login_required
|
|
909
|
+
def charger_detail(request, cid, connector=None):
|
|
910
|
+
charger, connector_slug = _get_charger(cid, connector)
|
|
911
|
+
access_response = _ensure_charger_access(
|
|
912
|
+
request.user, charger, request=request
|
|
913
|
+
)
|
|
914
|
+
if access_response is not None:
|
|
915
|
+
return access_response
|
|
916
|
+
|
|
917
|
+
sessions: list[tuple[Charger, Transaction]] = []
|
|
918
|
+
tx_obj = store.get_transaction(cid, charger.connector_id)
|
|
919
|
+
if charger.connector_id is None:
|
|
920
|
+
sessions = _live_sessions(charger)
|
|
921
|
+
if sessions:
|
|
922
|
+
tx_obj = sessions[0][1]
|
|
923
|
+
elif tx_obj:
|
|
924
|
+
sessions = [(charger, tx_obj)]
|
|
925
|
+
if not tx_obj:
|
|
926
|
+
tx_obj = (
|
|
927
|
+
Transaction.objects.filter(charger__charger_id=cid)
|
|
928
|
+
.order_by("-start_time")
|
|
929
|
+
.first()
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
tx_data = None
|
|
933
|
+
if tx_obj:
|
|
934
|
+
tx_data = {
|
|
935
|
+
"transactionId": tx_obj.pk,
|
|
936
|
+
"meterStart": tx_obj.meter_start,
|
|
937
|
+
"startTime": tx_obj.start_time.isoformat(),
|
|
938
|
+
}
|
|
939
|
+
identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
|
|
940
|
+
if identifier:
|
|
941
|
+
tx_data["vid"] = identifier
|
|
942
|
+
legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
|
|
943
|
+
if legacy_vin:
|
|
944
|
+
tx_data["vin"] = legacy_vin
|
|
945
|
+
if tx_obj.meter_stop is not None:
|
|
946
|
+
tx_data["meterStop"] = tx_obj.meter_stop
|
|
947
|
+
if tx_obj.stop_time is not None:
|
|
948
|
+
tx_data["stopTime"] = tx_obj.stop_time.isoformat()
|
|
949
|
+
|
|
950
|
+
active_transactions = []
|
|
951
|
+
for session_charger, session_tx in sessions:
|
|
952
|
+
payload = {
|
|
953
|
+
"charger_id": session_charger.charger_id,
|
|
954
|
+
"connector_id": session_charger.connector_id,
|
|
955
|
+
"connector_slug": session_charger.connector_slug,
|
|
956
|
+
"transactionId": session_tx.pk,
|
|
957
|
+
"meterStart": session_tx.meter_start,
|
|
958
|
+
"startTime": session_tx.start_time.isoformat(),
|
|
959
|
+
}
|
|
960
|
+
identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
|
|
961
|
+
if identifier:
|
|
962
|
+
payload["vid"] = identifier
|
|
963
|
+
legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
|
|
964
|
+
if legacy_vin:
|
|
965
|
+
payload["vin"] = legacy_vin
|
|
966
|
+
if session_tx.meter_stop is not None:
|
|
967
|
+
payload["meterStop"] = session_tx.meter_stop
|
|
968
|
+
if session_tx.stop_time is not None:
|
|
969
|
+
payload["stopTime"] = session_tx.stop_time.isoformat()
|
|
970
|
+
active_transactions.append(payload)
|
|
971
|
+
|
|
972
|
+
log_key = store.identity_key(cid, charger.connector_id)
|
|
973
|
+
log = store.get_logs(log_key, log_type="charger")
|
|
974
|
+
state, color = _charger_state(
|
|
975
|
+
charger,
|
|
976
|
+
tx_obj if charger.connector_id is not None else (sessions if sessions else None),
|
|
977
|
+
)
|
|
978
|
+
payload = {
|
|
979
|
+
"charger_id": cid,
|
|
980
|
+
"connector_id": charger.connector_id,
|
|
981
|
+
"connector_slug": connector_slug,
|
|
982
|
+
"name": charger.name,
|
|
983
|
+
"require_rfid": charger.require_rfid,
|
|
984
|
+
"transaction": tx_data,
|
|
985
|
+
"activeTransactions": active_transactions,
|
|
986
|
+
"lastHeartbeat": (
|
|
987
|
+
charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
|
|
988
|
+
),
|
|
989
|
+
"lastMeterValues": charger.last_meter_values,
|
|
990
|
+
"firmwareStatus": charger.firmware_status,
|
|
991
|
+
"firmwareStatusInfo": charger.firmware_status_info,
|
|
992
|
+
"firmwareTimestamp": (
|
|
993
|
+
charger.firmware_timestamp.isoformat()
|
|
994
|
+
if charger.firmware_timestamp
|
|
995
|
+
else None
|
|
996
|
+
),
|
|
997
|
+
"log": log,
|
|
998
|
+
"lastStatus": charger.last_status or None,
|
|
999
|
+
"lastErrorCode": charger.last_error_code or None,
|
|
1000
|
+
"lastStatusTimestamp": (
|
|
1001
|
+
charger.last_status_timestamp.isoformat()
|
|
1002
|
+
if charger.last_status_timestamp
|
|
1003
|
+
else None
|
|
1004
|
+
),
|
|
1005
|
+
"lastStatusVendorInfo": charger.last_status_vendor_info,
|
|
1006
|
+
"status": state,
|
|
1007
|
+
"statusColor": color,
|
|
1008
|
+
}
|
|
1009
|
+
payload.update(_diagnostics_payload(charger))
|
|
1010
|
+
return JsonResponse(payload)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
@landing("CPMS Online Dashboard")
|
|
1014
|
+
@live_update()
|
|
1015
|
+
def dashboard(request):
|
|
1016
|
+
"""Landing page listing all known chargers and their status."""
|
|
1017
|
+
node = Node.get_local()
|
|
1018
|
+
role = node.role if node else None
|
|
1019
|
+
role_name = role.name if role else ""
|
|
1020
|
+
allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
|
|
1021
|
+
if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
|
|
1022
|
+
return redirect_to_login(
|
|
1023
|
+
request.get_full_path(), login_url=reverse("pages:login")
|
|
1024
|
+
)
|
|
1025
|
+
is_watchtower = role_name in {"Watchtower", "Constellation"}
|
|
1026
|
+
visible_chargers = (
|
|
1027
|
+
_visible_chargers(request.user)
|
|
1028
|
+
.select_related("location")
|
|
1029
|
+
.order_by("charger_id", "connector_id")
|
|
1030
|
+
)
|
|
1031
|
+
stats_cache: dict[int, dict[str, float]] = {}
|
|
1032
|
+
|
|
1033
|
+
def _charger_display_name(charger: Charger) -> str:
|
|
1034
|
+
if charger.display_name:
|
|
1035
|
+
return charger.display_name
|
|
1036
|
+
if charger.location:
|
|
1037
|
+
return charger.location.name
|
|
1038
|
+
return charger.charger_id
|
|
1039
|
+
|
|
1040
|
+
today = timezone.localdate()
|
|
1041
|
+
tz = timezone.get_current_timezone()
|
|
1042
|
+
day_start = datetime.combine(today, time.min)
|
|
1043
|
+
if timezone.is_naive(day_start):
|
|
1044
|
+
day_start = timezone.make_aware(day_start, tz)
|
|
1045
|
+
day_end = day_start + timedelta(days=1)
|
|
1046
|
+
|
|
1047
|
+
def _charger_stats(charger: Charger) -> dict[str, float]:
|
|
1048
|
+
cache_key = charger.pk or id(charger)
|
|
1049
|
+
if cache_key not in stats_cache:
|
|
1050
|
+
stats_cache[cache_key] = {
|
|
1051
|
+
"total_kw": charger.total_kw,
|
|
1052
|
+
"today_kw": charger.total_kw_for_range(day_start, day_end),
|
|
1053
|
+
}
|
|
1054
|
+
return stats_cache[cache_key]
|
|
1055
|
+
|
|
1056
|
+
def _status_url(charger: Charger) -> str:
|
|
1057
|
+
return _reverse_connector_url(
|
|
1058
|
+
"charger-status",
|
|
1059
|
+
charger.charger_id,
|
|
1060
|
+
charger.connector_slug,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
chargers: list[dict[str, object]] = []
|
|
1064
|
+
charger_groups: list[dict[str, object]] = []
|
|
1065
|
+
group_lookup: dict[str, dict[str, object]] = {}
|
|
1066
|
+
|
|
1067
|
+
for charger in visible_chargers:
|
|
1068
|
+
tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
|
|
1069
|
+
if not tx_obj:
|
|
1070
|
+
tx_obj = (
|
|
1071
|
+
Transaction.objects.filter(charger=charger)
|
|
1072
|
+
.order_by("-start_time")
|
|
1073
|
+
.first()
|
|
1074
|
+
)
|
|
1075
|
+
has_session = _has_active_session(tx_obj)
|
|
1076
|
+
state, color = _charger_state(charger, tx_obj)
|
|
1077
|
+
if (
|
|
1078
|
+
charger.connector_id is not None
|
|
1079
|
+
and not has_session
|
|
1080
|
+
and (charger.last_status or "").strip().casefold() == "charging"
|
|
1081
|
+
):
|
|
1082
|
+
state, color = STATUS_BADGE_MAP["charging"]
|
|
1083
|
+
entry = {
|
|
1084
|
+
"charger": charger,
|
|
1085
|
+
"state": state,
|
|
1086
|
+
"color": color,
|
|
1087
|
+
"display_name": _charger_display_name(charger),
|
|
1088
|
+
"stats": _charger_stats(charger),
|
|
1089
|
+
"status_url": _status_url(charger),
|
|
1090
|
+
}
|
|
1091
|
+
chargers.append(entry)
|
|
1092
|
+
if charger.connector_id is None:
|
|
1093
|
+
group = {"parent": entry, "children": []}
|
|
1094
|
+
charger_groups.append(group)
|
|
1095
|
+
group_lookup[charger.charger_id] = group
|
|
1096
|
+
else:
|
|
1097
|
+
group = group_lookup.get(charger.charger_id)
|
|
1098
|
+
if group is None:
|
|
1099
|
+
group = {"parent": None, "children": []}
|
|
1100
|
+
charger_groups.append(group)
|
|
1101
|
+
group_lookup[charger.charger_id] = group
|
|
1102
|
+
group["children"].append(entry)
|
|
1103
|
+
|
|
1104
|
+
for group in charger_groups:
|
|
1105
|
+
parent_entry = group.get("parent")
|
|
1106
|
+
if not parent_entry or not group["children"]:
|
|
1107
|
+
continue
|
|
1108
|
+
connector_statuses = [
|
|
1109
|
+
(child["charger"].last_status or "").strip().casefold()
|
|
1110
|
+
for child in group["children"]
|
|
1111
|
+
if child["charger"].connector_id is not None
|
|
1112
|
+
]
|
|
1113
|
+
if connector_statuses and all(status == "charging" for status in connector_statuses):
|
|
1114
|
+
label, badge_color = STATUS_BADGE_MAP["charging"]
|
|
1115
|
+
parent_entry["state"] = label
|
|
1116
|
+
parent_entry["color"] = badge_color
|
|
1117
|
+
scheme = "wss" if request.is_secure() else "ws"
|
|
1118
|
+
host = request.get_host()
|
|
1119
|
+
ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
|
|
1120
|
+
context = {
|
|
1121
|
+
"chargers": chargers,
|
|
1122
|
+
"charger_groups": charger_groups,
|
|
1123
|
+
"show_demo_notice": is_watchtower,
|
|
1124
|
+
"demo_ws_url": ws_url,
|
|
1125
|
+
"ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
|
|
1126
|
+
}
|
|
1127
|
+
if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
|
|
1128
|
+
html = render_to_string(
|
|
1129
|
+
"ocpp/includes/dashboard_table_rows.html", context, request=request
|
|
1130
|
+
)
|
|
1131
|
+
return JsonResponse({"html": html})
|
|
1132
|
+
return render(request, "ocpp/dashboard.html", context)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@login_required(login_url="pages:login")
|
|
1136
|
+
@landing("Charge Point Simulator")
|
|
1137
|
+
@live_update()
|
|
1138
|
+
def cp_simulator(request):
|
|
1139
|
+
"""Public landing page to control the OCPP charge point simulator."""
|
|
1140
|
+
host_header = request.get_host()
|
|
1141
|
+
default_host, host_port = split_domain_port(host_header)
|
|
1142
|
+
if not default_host:
|
|
1143
|
+
default_host = "127.0.0.1"
|
|
1144
|
+
default_ws_port = request.get_port() or host_port or "8000"
|
|
1145
|
+
default_cp_paths = ["CP1", "CP2"]
|
|
1146
|
+
default_serial_numbers = default_cp_paths
|
|
1147
|
+
default_connector_id = 1
|
|
1148
|
+
default_rfid = "FFFFFFFF"
|
|
1149
|
+
default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
|
|
1150
|
+
|
|
1151
|
+
message = ""
|
|
1152
|
+
dashboard_link: str | None = None
|
|
1153
|
+
if request.method == "POST":
|
|
1154
|
+
cp_idx = int(request.POST.get("cp") or 1)
|
|
1155
|
+
action = request.POST.get("action")
|
|
1156
|
+
if action == "start":
|
|
1157
|
+
ws_port_value = request.POST.get("ws_port")
|
|
1158
|
+
if ws_port_value is None:
|
|
1159
|
+
ws_port = int(default_ws_port) if default_ws_port else None
|
|
1160
|
+
elif ws_port_value.strip():
|
|
1161
|
+
ws_port = int(ws_port_value)
|
|
1162
|
+
else:
|
|
1163
|
+
ws_port = None
|
|
1164
|
+
sim_params = dict(
|
|
1165
|
+
host=request.POST.get("host") or default_host,
|
|
1166
|
+
ws_port=ws_port,
|
|
1167
|
+
cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
|
|
1168
|
+
serial_number=request.POST.get("serial_number")
|
|
1169
|
+
or default_serial_numbers[cp_idx - 1],
|
|
1170
|
+
connector_id=int(
|
|
1171
|
+
request.POST.get("connector_id") or default_connector_id
|
|
1172
|
+
),
|
|
1173
|
+
rfid=request.POST.get("rfid") or default_rfid,
|
|
1174
|
+
vin=request.POST.get("vin") or default_vins[cp_idx - 1],
|
|
1175
|
+
duration=int(request.POST.get("duration") or 600),
|
|
1176
|
+
interval=float(request.POST.get("interval") or 5),
|
|
1177
|
+
kw_min=float(request.POST.get("kw_min") or 30),
|
|
1178
|
+
kw_max=float(request.POST.get("kw_max") or 60),
|
|
1179
|
+
pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
|
|
1180
|
+
repeat=request.POST.get("repeat") or False,
|
|
1181
|
+
daemon=True,
|
|
1182
|
+
username=request.POST.get("username") or None,
|
|
1183
|
+
password=request.POST.get("password") or None,
|
|
1184
|
+
)
|
|
1185
|
+
try:
|
|
1186
|
+
started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
|
|
1187
|
+
if started:
|
|
1188
|
+
message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
|
|
1189
|
+
try:
|
|
1190
|
+
dashboard_link = reverse(
|
|
1191
|
+
"charger-status", args=[sim_params["cp_path"]]
|
|
1192
|
+
)
|
|
1193
|
+
except NoReverseMatch: # pragma: no cover - defensive
|
|
1194
|
+
dashboard_link = None
|
|
1195
|
+
else:
|
|
1196
|
+
message = f"CP{cp_idx} {status}. Logs: {log_file}"
|
|
1197
|
+
except Exception as exc: # pragma: no cover - unexpected
|
|
1198
|
+
message = f"Failed to start CP{cp_idx}: {exc}"
|
|
1199
|
+
elif action == "stop":
|
|
1200
|
+
try:
|
|
1201
|
+
_stop_simulator(cp=cp_idx)
|
|
1202
|
+
message = f"CP{cp_idx} stop requested."
|
|
1203
|
+
except Exception as exc: # pragma: no cover - unexpected
|
|
1204
|
+
message = f"Failed to stop CP{cp_idx}: {exc}"
|
|
1205
|
+
else:
|
|
1206
|
+
message = "Unknown action."
|
|
1207
|
+
|
|
1208
|
+
states_dict = get_simulator_state()
|
|
1209
|
+
state_list = [states_dict[1], states_dict[2]]
|
|
1210
|
+
params_jsons = [
|
|
1211
|
+
json.dumps(state_list[0].get("params", {}), indent=2),
|
|
1212
|
+
json.dumps(state_list[1].get("params", {}), indent=2),
|
|
1213
|
+
]
|
|
1214
|
+
state_jsons = [
|
|
1215
|
+
_simulator_status_json(1),
|
|
1216
|
+
_simulator_status_json(2),
|
|
1217
|
+
]
|
|
1218
|
+
|
|
1219
|
+
context = {
|
|
1220
|
+
"message": message,
|
|
1221
|
+
"dashboard_link": dashboard_link,
|
|
1222
|
+
"states": state_list,
|
|
1223
|
+
"default_host": default_host,
|
|
1224
|
+
"default_ws_port": default_ws_port,
|
|
1225
|
+
"default_cp_paths": default_cp_paths,
|
|
1226
|
+
"default_serial_numbers": default_serial_numbers,
|
|
1227
|
+
"default_connector_id": default_connector_id,
|
|
1228
|
+
"default_rfid": default_rfid,
|
|
1229
|
+
"default_vins": default_vins,
|
|
1230
|
+
"params_jsons": params_jsons,
|
|
1231
|
+
"state_jsons": state_jsons,
|
|
1232
|
+
}
|
|
1233
|
+
return render(request, "ocpp/cp_simulator.html", context)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def charger_page(request, cid, connector=None):
|
|
1237
|
+
"""Public landing page for a charger displaying usage guidance or progress."""
|
|
1238
|
+
charger, connector_slug = _get_charger(cid, connector)
|
|
1239
|
+
access_response = _ensure_charger_access(
|
|
1240
|
+
request.user, charger, request=request
|
|
1241
|
+
)
|
|
1242
|
+
if access_response is not None:
|
|
1243
|
+
return access_response
|
|
1244
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1245
|
+
overview = _connector_overview(
|
|
1246
|
+
charger, request.user, rfid_cache=rfid_cache
|
|
1247
|
+
)
|
|
1248
|
+
sessions = _live_sessions(charger)
|
|
1249
|
+
tx = None
|
|
1250
|
+
active_connector_count = 0
|
|
1251
|
+
if charger.connector_id is None:
|
|
1252
|
+
if sessions:
|
|
1253
|
+
total_kw = 0.0
|
|
1254
|
+
start_times = [
|
|
1255
|
+
tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
|
|
1256
|
+
]
|
|
1257
|
+
for _, tx_obj in sessions:
|
|
1258
|
+
if tx_obj.kw:
|
|
1259
|
+
total_kw += tx_obj.kw
|
|
1260
|
+
tx = SimpleNamespace(
|
|
1261
|
+
kw=total_kw, start_time=min(start_times) if start_times else None
|
|
1262
|
+
)
|
|
1263
|
+
active_connector_count = len(sessions)
|
|
1264
|
+
else:
|
|
1265
|
+
tx = (
|
|
1266
|
+
sessions[0][1]
|
|
1267
|
+
if sessions
|
|
1268
|
+
else store.get_transaction(cid, charger.connector_id)
|
|
1269
|
+
)
|
|
1270
|
+
if tx:
|
|
1271
|
+
active_connector_count = 1
|
|
1272
|
+
state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
|
|
1273
|
+
state, color = _charger_state(charger, state_source)
|
|
1274
|
+
language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
|
|
1275
|
+
available_languages = [
|
|
1276
|
+
str(code).strip()
|
|
1277
|
+
for code, _ in settings.LANGUAGES
|
|
1278
|
+
if str(code).strip()
|
|
1279
|
+
]
|
|
1280
|
+
supported_languages = set(available_languages)
|
|
1281
|
+
charger_language = (charger.language or "es").strip()
|
|
1282
|
+
if charger_language not in supported_languages:
|
|
1283
|
+
fallback = "es" if "es" in supported_languages else ""
|
|
1284
|
+
if not fallback and available_languages:
|
|
1285
|
+
fallback = available_languages[0]
|
|
1286
|
+
charger_language = fallback
|
|
1287
|
+
if (
|
|
1288
|
+
charger_language
|
|
1289
|
+
and (
|
|
1290
|
+
not language_cookie
|
|
1291
|
+
or language_cookie not in supported_languages
|
|
1292
|
+
or language_cookie != charger_language
|
|
1293
|
+
)
|
|
1294
|
+
):
|
|
1295
|
+
translation.activate(charger_language)
|
|
1296
|
+
current_language = translation.get_language()
|
|
1297
|
+
request.LANGUAGE_CODE = current_language
|
|
1298
|
+
preferred_language = charger_language or current_language
|
|
1299
|
+
connector_links = [
|
|
1300
|
+
{
|
|
1301
|
+
"slug": item["slug"],
|
|
1302
|
+
"label": item["label"],
|
|
1303
|
+
"url": item["url"],
|
|
1304
|
+
"active": item["slug"] == connector_slug,
|
|
1305
|
+
}
|
|
1306
|
+
for item in overview
|
|
1307
|
+
]
|
|
1308
|
+
connector_overview = [
|
|
1309
|
+
item for item in overview if item["charger"].connector_id is not None
|
|
1310
|
+
]
|
|
1311
|
+
status_url = _reverse_connector_url("charger-status", cid, connector_slug)
|
|
1312
|
+
tx_rfid_details = _transaction_rfid_details(tx, cache=rfid_cache)
|
|
1313
|
+
return render(
|
|
1314
|
+
request,
|
|
1315
|
+
"ocpp/charger_page.html",
|
|
1316
|
+
{
|
|
1317
|
+
"charger": charger,
|
|
1318
|
+
"tx": tx,
|
|
1319
|
+
"tx_rfid_details": tx_rfid_details,
|
|
1320
|
+
"connector_slug": connector_slug,
|
|
1321
|
+
"connector_links": connector_links,
|
|
1322
|
+
"connector_overview": connector_overview,
|
|
1323
|
+
"active_connector_count": active_connector_count,
|
|
1324
|
+
"status_url": status_url,
|
|
1325
|
+
"landing_translations": _landing_page_translations(),
|
|
1326
|
+
"preferred_language": preferred_language,
|
|
1327
|
+
"state": state,
|
|
1328
|
+
"color": color,
|
|
1329
|
+
},
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
@login_required
|
|
1334
|
+
def charger_status(request, cid, connector=None):
|
|
1335
|
+
charger, connector_slug = _get_charger(cid, connector)
|
|
1336
|
+
access_response = _ensure_charger_access(
|
|
1337
|
+
request.user, charger, request=request
|
|
1338
|
+
)
|
|
1339
|
+
if access_response is not None:
|
|
1340
|
+
return access_response
|
|
1341
|
+
session_id = request.GET.get("session")
|
|
1342
|
+
sessions = _live_sessions(charger)
|
|
1343
|
+
live_tx = None
|
|
1344
|
+
if charger.connector_id is not None and sessions:
|
|
1345
|
+
live_tx = sessions[0][1]
|
|
1346
|
+
tx_obj = live_tx
|
|
1347
|
+
past_session = False
|
|
1348
|
+
if session_id:
|
|
1349
|
+
if charger.connector_id is None:
|
|
1350
|
+
tx_obj = get_object_or_404(
|
|
1351
|
+
Transaction, pk=session_id, charger__charger_id=cid
|
|
1352
|
+
)
|
|
1353
|
+
past_session = True
|
|
1354
|
+
elif not (live_tx and str(live_tx.pk) == session_id):
|
|
1355
|
+
tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
|
|
1356
|
+
past_session = True
|
|
1357
|
+
state, color = _charger_state(
|
|
1358
|
+
charger,
|
|
1359
|
+
(
|
|
1360
|
+
live_tx
|
|
1361
|
+
if charger.connector_id is not None
|
|
1362
|
+
else (sessions if sessions else None)
|
|
1363
|
+
),
|
|
1364
|
+
)
|
|
1365
|
+
if charger.connector_id is None:
|
|
1366
|
+
transactions_qs = (
|
|
1367
|
+
Transaction.objects.filter(charger__charger_id=cid)
|
|
1368
|
+
.select_related("charger")
|
|
1369
|
+
.order_by("-start_time")
|
|
1370
|
+
)
|
|
1371
|
+
else:
|
|
1372
|
+
transactions_qs = Transaction.objects.filter(charger=charger).order_by(
|
|
1373
|
+
"-start_time"
|
|
1374
|
+
)
|
|
1375
|
+
paginator = Paginator(transactions_qs, 10)
|
|
1376
|
+
page_obj = paginator.get_page(request.GET.get("page"))
|
|
1377
|
+
transactions = page_obj.object_list
|
|
1378
|
+
date_view = request.GET.get("dates", "charger").lower()
|
|
1379
|
+
if date_view not in {"charger", "received"}:
|
|
1380
|
+
date_view = "charger"
|
|
1381
|
+
|
|
1382
|
+
def _date_query(mode: str) -> str:
|
|
1383
|
+
params = request.GET.copy()
|
|
1384
|
+
params["dates"] = mode
|
|
1385
|
+
query = params.urlencode()
|
|
1386
|
+
return f"?{query}" if query else ""
|
|
1387
|
+
|
|
1388
|
+
date_view_options = {
|
|
1389
|
+
"charger": _("Charger timestamps"),
|
|
1390
|
+
"received": _("Received timestamps"),
|
|
1391
|
+
}
|
|
1392
|
+
date_toggle_links = [
|
|
1393
|
+
{
|
|
1394
|
+
"mode": mode,
|
|
1395
|
+
"label": label,
|
|
1396
|
+
"url": _date_query(mode),
|
|
1397
|
+
"active": mode == date_view,
|
|
1398
|
+
}
|
|
1399
|
+
for mode, label in date_view_options.items()
|
|
1400
|
+
]
|
|
1401
|
+
chart_data = {"labels": [], "datasets": []}
|
|
1402
|
+
pagination_params = request.GET.copy()
|
|
1403
|
+
pagination_params["dates"] = date_view
|
|
1404
|
+
pagination_params.pop("page", None)
|
|
1405
|
+
pagination_query = pagination_params.urlencode()
|
|
1406
|
+
session_params = request.GET.copy()
|
|
1407
|
+
session_params["dates"] = date_view
|
|
1408
|
+
session_params.pop("session", None)
|
|
1409
|
+
session_params.pop("page", None)
|
|
1410
|
+
session_query = session_params.urlencode()
|
|
1411
|
+
|
|
1412
|
+
def _series_from_transaction(tx):
|
|
1413
|
+
points: list[tuple[str, float]] = []
|
|
1414
|
+
readings = list(
|
|
1415
|
+
tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
|
|
1416
|
+
)
|
|
1417
|
+
start_val = None
|
|
1418
|
+
if tx.meter_start is not None:
|
|
1419
|
+
start_val = float(tx.meter_start) / 1000.0
|
|
1420
|
+
for reading in readings:
|
|
1421
|
+
try:
|
|
1422
|
+
val = float(reading.energy)
|
|
1423
|
+
except (TypeError, ValueError):
|
|
1424
|
+
continue
|
|
1425
|
+
if start_val is None:
|
|
1426
|
+
start_val = val
|
|
1427
|
+
total = val - start_val
|
|
1428
|
+
points.append((reading.timestamp.isoformat(), max(total, 0.0)))
|
|
1429
|
+
return points
|
|
1430
|
+
|
|
1431
|
+
if tx_obj and (charger.connector_id is not None or past_session):
|
|
1432
|
+
series_points = _series_from_transaction(tx_obj)
|
|
1433
|
+
if series_points:
|
|
1434
|
+
chart_data["labels"] = [ts for ts, _ in series_points]
|
|
1435
|
+
connector_id = None
|
|
1436
|
+
if tx_obj.charger and tx_obj.charger.connector_id is not None:
|
|
1437
|
+
connector_id = tx_obj.charger.connector_id
|
|
1438
|
+
elif charger.connector_id is not None:
|
|
1439
|
+
connector_id = charger.connector_id
|
|
1440
|
+
chart_data["datasets"].append(
|
|
1441
|
+
{
|
|
1442
|
+
"label": str(
|
|
1443
|
+
tx_obj.charger.connector_label
|
|
1444
|
+
if tx_obj.charger and tx_obj.charger.connector_id is not None
|
|
1445
|
+
else charger.connector_label
|
|
1446
|
+
),
|
|
1447
|
+
"values": [value for _, value in series_points],
|
|
1448
|
+
"connector_id": connector_id,
|
|
1449
|
+
}
|
|
1450
|
+
)
|
|
1451
|
+
elif charger.connector_id is None:
|
|
1452
|
+
dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
|
|
1453
|
+
for sibling, sibling_tx in sessions:
|
|
1454
|
+
if sibling.connector_id is None or not sibling_tx:
|
|
1455
|
+
continue
|
|
1456
|
+
points = _series_from_transaction(sibling_tx)
|
|
1457
|
+
if not points:
|
|
1458
|
+
continue
|
|
1459
|
+
dataset_points.append(
|
|
1460
|
+
(str(sibling.connector_label), points, sibling.connector_id)
|
|
1461
|
+
)
|
|
1462
|
+
if dataset_points:
|
|
1463
|
+
all_labels: list[str] = sorted(
|
|
1464
|
+
{ts for _, points, _ in dataset_points for ts, _ in points}
|
|
1465
|
+
)
|
|
1466
|
+
chart_data["labels"] = all_labels
|
|
1467
|
+
for label, points, connector_id in dataset_points:
|
|
1468
|
+
value_map = {ts: val for ts, val in points}
|
|
1469
|
+
chart_data["datasets"].append(
|
|
1470
|
+
{
|
|
1471
|
+
"label": label,
|
|
1472
|
+
"values": [value_map.get(ts) for ts in all_labels],
|
|
1473
|
+
"connector_id": connector_id,
|
|
1474
|
+
}
|
|
1475
|
+
)
|
|
1476
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1477
|
+
overview = _connector_overview(
|
|
1478
|
+
charger, request.user, rfid_cache=rfid_cache
|
|
1479
|
+
)
|
|
1480
|
+
connector_links = [
|
|
1481
|
+
{
|
|
1482
|
+
"slug": item["slug"],
|
|
1483
|
+
"label": item["label"],
|
|
1484
|
+
"url": _reverse_connector_url("charger-status", cid, item["slug"]),
|
|
1485
|
+
"active": item["slug"] == connector_slug,
|
|
1486
|
+
}
|
|
1487
|
+
for item in overview
|
|
1488
|
+
]
|
|
1489
|
+
connector_overview = [
|
|
1490
|
+
item for item in overview if item["charger"].connector_id is not None
|
|
1491
|
+
]
|
|
1492
|
+
usage_timeline, usage_timeline_window = _usage_timeline(
|
|
1493
|
+
charger, connector_overview
|
|
1494
|
+
)
|
|
1495
|
+
search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
|
|
1496
|
+
configuration_url = None
|
|
1497
|
+
if request.user.is_staff:
|
|
1498
|
+
try:
|
|
1499
|
+
configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
1500
|
+
except NoReverseMatch: # pragma: no cover - admin may be disabled
|
|
1501
|
+
configuration_url = None
|
|
1502
|
+
is_connected = store.is_connected(cid, charger.connector_id)
|
|
1503
|
+
has_active_session = bool(
|
|
1504
|
+
live_tx if charger.connector_id is not None else sessions
|
|
1505
|
+
)
|
|
1506
|
+
can_remote_start = (
|
|
1507
|
+
charger.connector_id is not None
|
|
1508
|
+
and is_connected
|
|
1509
|
+
and not has_active_session
|
|
1510
|
+
and not past_session
|
|
1511
|
+
)
|
|
1512
|
+
remote_start_messages = None
|
|
1513
|
+
if can_remote_start:
|
|
1514
|
+
remote_start_messages = {
|
|
1515
|
+
"required": str(_("RFID is required to start a session.")),
|
|
1516
|
+
"sending": str(_("Sending remote start request...")),
|
|
1517
|
+
"success": str(_("Remote start command queued.")),
|
|
1518
|
+
"error": str(_("Unable to send remote start request.")),
|
|
1519
|
+
}
|
|
1520
|
+
action_url = _reverse_connector_url("charger-action", cid, connector_slug)
|
|
1521
|
+
chart_should_animate = bool(has_active_session and not past_session)
|
|
1522
|
+
|
|
1523
|
+
tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
|
|
1524
|
+
|
|
1525
|
+
return render(
|
|
1526
|
+
request,
|
|
1527
|
+
"ocpp/charger_status.html",
|
|
1528
|
+
{
|
|
1529
|
+
"charger": charger,
|
|
1530
|
+
"tx": tx_obj,
|
|
1531
|
+
"tx_rfid_details": tx_rfid_details,
|
|
1532
|
+
"state": state,
|
|
1533
|
+
"color": color,
|
|
1534
|
+
"transactions": transactions,
|
|
1535
|
+
"page_obj": page_obj,
|
|
1536
|
+
"chart_data": chart_data,
|
|
1537
|
+
"past_session": past_session,
|
|
1538
|
+
"connector_slug": connector_slug,
|
|
1539
|
+
"connector_links": connector_links,
|
|
1540
|
+
"connector_overview": connector_overview,
|
|
1541
|
+
"search_url": search_url,
|
|
1542
|
+
"configuration_url": configuration_url,
|
|
1543
|
+
"page_url": _reverse_connector_url("charger-page", cid, connector_slug),
|
|
1544
|
+
"is_connected": is_connected,
|
|
1545
|
+
"is_idle": is_connected and not has_active_session,
|
|
1546
|
+
"can_remote_start": can_remote_start,
|
|
1547
|
+
"remote_start_messages": remote_start_messages,
|
|
1548
|
+
"action_url": action_url,
|
|
1549
|
+
"show_chart": bool(
|
|
1550
|
+
chart_data["datasets"]
|
|
1551
|
+
and any(
|
|
1552
|
+
any(value is not None for value in dataset["values"])
|
|
1553
|
+
for dataset in chart_data["datasets"]
|
|
1554
|
+
)
|
|
1555
|
+
),
|
|
1556
|
+
"date_view": date_view,
|
|
1557
|
+
"date_toggle_links": date_toggle_links,
|
|
1558
|
+
"pagination_query": pagination_query,
|
|
1559
|
+
"session_query": session_query,
|
|
1560
|
+
"chart_should_animate": chart_should_animate,
|
|
1561
|
+
"usage_timeline": usage_timeline,
|
|
1562
|
+
"usage_timeline_window": usage_timeline_window,
|
|
1563
|
+
},
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
@login_required
|
|
1568
|
+
def charger_session_search(request, cid, connector=None):
|
|
1569
|
+
charger, connector_slug = _get_charger(cid, connector)
|
|
1570
|
+
access_response = _ensure_charger_access(
|
|
1571
|
+
request.user, charger, request=request
|
|
1572
|
+
)
|
|
1573
|
+
if access_response is not None:
|
|
1574
|
+
return access_response
|
|
1575
|
+
date_str = request.GET.get("date")
|
|
1576
|
+
date_view = request.GET.get("dates", "charger").lower()
|
|
1577
|
+
if date_view not in {"charger", "received"}:
|
|
1578
|
+
date_view = "charger"
|
|
1579
|
+
|
|
1580
|
+
def _date_query(mode: str) -> str:
|
|
1581
|
+
params = request.GET.copy()
|
|
1582
|
+
params["dates"] = mode
|
|
1583
|
+
query = params.urlencode()
|
|
1584
|
+
return f"?{query}" if query else ""
|
|
1585
|
+
|
|
1586
|
+
date_toggle_links = [
|
|
1587
|
+
{
|
|
1588
|
+
"mode": mode,
|
|
1589
|
+
"label": label,
|
|
1590
|
+
"url": _date_query(mode),
|
|
1591
|
+
"active": mode == date_view,
|
|
1592
|
+
}
|
|
1593
|
+
for mode, label in {
|
|
1594
|
+
"charger": _("Charger timestamps"),
|
|
1595
|
+
"received": _("Received timestamps"),
|
|
1596
|
+
}.items()
|
|
1597
|
+
]
|
|
1598
|
+
transactions = None
|
|
1599
|
+
if date_str:
|
|
1600
|
+
try:
|
|
1601
|
+
date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
1602
|
+
start = datetime.combine(
|
|
1603
|
+
date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
|
|
1604
|
+
)
|
|
1605
|
+
end = start + timedelta(days=1)
|
|
1606
|
+
qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
|
|
1607
|
+
if charger.connector_id is None:
|
|
1608
|
+
qs = qs.filter(charger__charger_id=cid)
|
|
1609
|
+
else:
|
|
1610
|
+
qs = qs.filter(charger=charger)
|
|
1611
|
+
transactions = qs.order_by("-start_time")
|
|
1612
|
+
except ValueError:
|
|
1613
|
+
transactions = []
|
|
1614
|
+
if transactions is not None:
|
|
1615
|
+
transactions = list(transactions)
|
|
1616
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1617
|
+
for tx in transactions:
|
|
1618
|
+
details = _transaction_rfid_details(tx, cache=rfid_cache)
|
|
1619
|
+
label_value = None
|
|
1620
|
+
if details:
|
|
1621
|
+
label_value = str(details.get("label") or "").strip() or None
|
|
1622
|
+
tx.rfid_label = label_value
|
|
1623
|
+
overview = _connector_overview(charger, request.user)
|
|
1624
|
+
connector_links = [
|
|
1625
|
+
{
|
|
1626
|
+
"slug": item["slug"],
|
|
1627
|
+
"label": item["label"],
|
|
1628
|
+
"url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
|
|
1629
|
+
"active": item["slug"] == connector_slug,
|
|
1630
|
+
}
|
|
1631
|
+
for item in overview
|
|
1632
|
+
]
|
|
1633
|
+
status_url = _reverse_connector_url("charger-status", cid, connector_slug)
|
|
1634
|
+
return render(
|
|
1635
|
+
request,
|
|
1636
|
+
"ocpp/charger_session_search.html",
|
|
1637
|
+
{
|
|
1638
|
+
"charger": charger,
|
|
1639
|
+
"transactions": transactions,
|
|
1640
|
+
"date": date_str,
|
|
1641
|
+
"connector_slug": connector_slug,
|
|
1642
|
+
"connector_links": connector_links,
|
|
1643
|
+
"status_url": status_url,
|
|
1644
|
+
"date_view": date_view,
|
|
1645
|
+
"date_toggle_links": date_toggle_links,
|
|
1646
|
+
},
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
@login_required
|
|
1651
|
+
def charger_log_page(request, cid, connector=None):
|
|
1652
|
+
"""Render a simple page with the log for the charger or simulator."""
|
|
1653
|
+
log_type = request.GET.get("type", "charger")
|
|
1654
|
+
connector_links = []
|
|
1655
|
+
connector_slug = None
|
|
1656
|
+
status_url = None
|
|
1657
|
+
if log_type == "charger":
|
|
1658
|
+
charger, connector_slug = _get_charger(cid, connector)
|
|
1659
|
+
access_response = _ensure_charger_access(
|
|
1660
|
+
request.user, charger, request=request
|
|
1661
|
+
)
|
|
1662
|
+
if access_response is not None:
|
|
1663
|
+
return access_response
|
|
1664
|
+
log_key = store.identity_key(cid, charger.connector_id)
|
|
1665
|
+
overview = _connector_overview(charger, request.user)
|
|
1666
|
+
connector_links = [
|
|
1667
|
+
{
|
|
1668
|
+
"slug": item["slug"],
|
|
1669
|
+
"label": item["label"],
|
|
1670
|
+
"url": _reverse_connector_url("charger-log", cid, item["slug"]),
|
|
1671
|
+
"active": item["slug"] == connector_slug,
|
|
1672
|
+
}
|
|
1673
|
+
for item in overview
|
|
1674
|
+
]
|
|
1675
|
+
target_id = log_key
|
|
1676
|
+
status_url = _reverse_connector_url("charger-status", cid, connector_slug)
|
|
1677
|
+
else:
|
|
1678
|
+
charger = Charger.objects.filter(charger_id=cid).first() or Charger(
|
|
1679
|
+
charger_id=cid
|
|
1680
|
+
)
|
|
1681
|
+
target_id = cid
|
|
1682
|
+
|
|
1683
|
+
slug_source = slugify(target_id) or slugify(cid) or "log"
|
|
1684
|
+
filename_parts = [log_type, slug_source]
|
|
1685
|
+
download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
|
|
1686
|
+
limit_options = [
|
|
1687
|
+
{"value": "20", "label": "20"},
|
|
1688
|
+
{"value": "40", "label": "40"},
|
|
1689
|
+
{"value": "100", "label": "100"},
|
|
1690
|
+
{"value": "all", "label": gettext("All")},
|
|
1691
|
+
]
|
|
1692
|
+
allowed_values = [item["value"] for item in limit_options]
|
|
1693
|
+
limit_choice = request.GET.get("limit", "20")
|
|
1694
|
+
if limit_choice not in allowed_values:
|
|
1695
|
+
limit_choice = "20"
|
|
1696
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1697
|
+
|
|
1698
|
+
log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1699
|
+
download_requested = request.GET.get("download") == "1"
|
|
1700
|
+
if download_requested:
|
|
1701
|
+
download_content = "\n".join(log_entries_all)
|
|
1702
|
+
if download_content and not download_content.endswith("\n"):
|
|
1703
|
+
download_content = f"{download_content}\n"
|
|
1704
|
+
response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
|
|
1705
|
+
response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
|
|
1706
|
+
return response
|
|
1707
|
+
|
|
1708
|
+
log_entries = log_entries_all
|
|
1709
|
+
if limit_choice != "all":
|
|
1710
|
+
try:
|
|
1711
|
+
limit_value = int(limit_choice)
|
|
1712
|
+
except (TypeError, ValueError):
|
|
1713
|
+
limit_value = 20
|
|
1714
|
+
limit_choice = "20"
|
|
1715
|
+
limit_index = allowed_values.index(limit_choice)
|
|
1716
|
+
log_entries = log_entries[-limit_value:]
|
|
1717
|
+
|
|
1718
|
+
download_params = request.GET.copy()
|
|
1719
|
+
download_params["download"] = "1"
|
|
1720
|
+
download_params.pop("limit", None)
|
|
1721
|
+
download_query = download_params.urlencode()
|
|
1722
|
+
log_download_url = f"{request.path}?{download_query}" if download_query else request.path
|
|
1723
|
+
|
|
1724
|
+
limit_label = limit_options[limit_index]["label"]
|
|
1725
|
+
log_content = "\n".join(log_entries)
|
|
1726
|
+
return render(
|
|
1727
|
+
request,
|
|
1728
|
+
"ocpp/charger_logs.html",
|
|
1729
|
+
{
|
|
1730
|
+
"charger": charger,
|
|
1731
|
+
"log": log_entries,
|
|
1732
|
+
"log_content": log_content,
|
|
1733
|
+
"log_type": log_type,
|
|
1734
|
+
"connector_slug": connector_slug,
|
|
1735
|
+
"connector_links": connector_links,
|
|
1736
|
+
"status_url": status_url,
|
|
1737
|
+
"log_limit_options": limit_options,
|
|
1738
|
+
"log_limit_index": limit_index,
|
|
1739
|
+
"log_limit_choice": limit_choice,
|
|
1740
|
+
"log_limit_label": limit_label,
|
|
1741
|
+
"log_download_url": log_download_url,
|
|
1742
|
+
"log_filename": download_filename,
|
|
1743
|
+
},
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
@csrf_exempt
|
|
1748
|
+
@api_login_required
|
|
1749
|
+
def dispatch_action(request, cid, connector=None):
|
|
1750
|
+
connector_value, _normalized_slug = _normalize_connector_slug(connector)
|
|
1751
|
+
log_key = store.identity_key(cid, connector_value)
|
|
1752
|
+
if connector_value is None:
|
|
1753
|
+
charger_obj = (
|
|
1754
|
+
Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
|
|
1755
|
+
.order_by("pk")
|
|
1756
|
+
.first()
|
|
1757
|
+
)
|
|
1758
|
+
else:
|
|
1759
|
+
charger_obj = (
|
|
1760
|
+
Charger.objects.filter(charger_id=cid, connector_id=connector_value)
|
|
1761
|
+
.order_by("pk")
|
|
1762
|
+
.first()
|
|
1763
|
+
)
|
|
1764
|
+
if charger_obj is None:
|
|
1765
|
+
if connector_value is None:
|
|
1766
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1767
|
+
charger_id=cid, connector_id=None
|
|
1768
|
+
)
|
|
1769
|
+
else:
|
|
1770
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1771
|
+
charger_id=cid, connector_id=connector_value
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
access_response = _ensure_charger_access(
|
|
1775
|
+
request.user, charger_obj, request=request
|
|
1776
|
+
)
|
|
1777
|
+
if access_response is not None:
|
|
1778
|
+
return access_response
|
|
1779
|
+
ws = store.get_connection(cid, connector_value)
|
|
1780
|
+
if ws is None:
|
|
1781
|
+
return JsonResponse({"detail": "no connection"}, status=404)
|
|
1782
|
+
try:
|
|
1783
|
+
data = json.loads(request.body.decode()) if request.body else {}
|
|
1784
|
+
except json.JSONDecodeError:
|
|
1785
|
+
data = {}
|
|
1786
|
+
action = data.get("action")
|
|
1787
|
+
message_id: str | None = None
|
|
1788
|
+
ocpp_action: str | None = None
|
|
1789
|
+
expected_statuses: set[str] | None = None
|
|
1790
|
+
msg: str | None = None
|
|
1791
|
+
if action == "remote_stop":
|
|
1792
|
+
tx_obj = store.get_transaction(cid, connector_value)
|
|
1793
|
+
if not tx_obj:
|
|
1794
|
+
return JsonResponse({"detail": "no transaction"}, status=404)
|
|
1795
|
+
message_id = uuid.uuid4().hex
|
|
1796
|
+
ocpp_action = "RemoteStopTransaction"
|
|
1797
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1798
|
+
msg = json.dumps(
|
|
1799
|
+
[
|
|
1800
|
+
2,
|
|
1801
|
+
message_id,
|
|
1802
|
+
"RemoteStopTransaction",
|
|
1803
|
+
{"transactionId": tx_obj.pk},
|
|
1804
|
+
]
|
|
1805
|
+
)
|
|
1806
|
+
async_to_sync(ws.send)(msg)
|
|
1807
|
+
store.register_pending_call(
|
|
1808
|
+
message_id,
|
|
1809
|
+
{
|
|
1810
|
+
"action": "RemoteStopTransaction",
|
|
1811
|
+
"charger_id": cid,
|
|
1812
|
+
"connector_id": connector_value,
|
|
1813
|
+
"log_key": log_key,
|
|
1814
|
+
"transaction_id": tx_obj.pk,
|
|
1815
|
+
"requested_at": timezone.now(),
|
|
1816
|
+
},
|
|
1817
|
+
)
|
|
1818
|
+
elif action == "remote_start":
|
|
1819
|
+
id_tag = data.get("idTag")
|
|
1820
|
+
if not isinstance(id_tag, str) or not id_tag.strip():
|
|
1821
|
+
return JsonResponse({"detail": "idTag required"}, status=400)
|
|
1822
|
+
id_tag = id_tag.strip()
|
|
1823
|
+
payload: dict[str, object] = {"idTag": id_tag}
|
|
1824
|
+
connector_id = data.get("connectorId")
|
|
1825
|
+
if connector_id in ("", None):
|
|
1826
|
+
connector_id = None
|
|
1827
|
+
if connector_id is None and connector_value is not None:
|
|
1828
|
+
connector_id = connector_value
|
|
1829
|
+
if connector_id is not None:
|
|
1830
|
+
try:
|
|
1831
|
+
payload["connectorId"] = int(connector_id)
|
|
1832
|
+
except (TypeError, ValueError):
|
|
1833
|
+
payload["connectorId"] = connector_id
|
|
1834
|
+
if "chargingProfile" in data and data["chargingProfile"] is not None:
|
|
1835
|
+
payload["chargingProfile"] = data["chargingProfile"]
|
|
1836
|
+
message_id = uuid.uuid4().hex
|
|
1837
|
+
ocpp_action = "RemoteStartTransaction"
|
|
1838
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1839
|
+
msg = json.dumps(
|
|
1840
|
+
[
|
|
1841
|
+
2,
|
|
1842
|
+
message_id,
|
|
1843
|
+
"RemoteStartTransaction",
|
|
1844
|
+
payload,
|
|
1845
|
+
]
|
|
1846
|
+
)
|
|
1847
|
+
async_to_sync(ws.send)(msg)
|
|
1848
|
+
store.register_pending_call(
|
|
1849
|
+
message_id,
|
|
1850
|
+
{
|
|
1851
|
+
"action": "RemoteStartTransaction",
|
|
1852
|
+
"charger_id": cid,
|
|
1853
|
+
"connector_id": connector_value,
|
|
1854
|
+
"log_key": log_key,
|
|
1855
|
+
"id_tag": id_tag,
|
|
1856
|
+
"requested_at": timezone.now(),
|
|
1857
|
+
},
|
|
1858
|
+
)
|
|
1859
|
+
elif action == "change_availability":
|
|
1860
|
+
availability_type = data.get("type")
|
|
1861
|
+
if availability_type not in {"Operative", "Inoperative"}:
|
|
1862
|
+
return JsonResponse({"detail": "invalid availability type"}, status=400)
|
|
1863
|
+
connector_payload = connector_value if connector_value is not None else 0
|
|
1864
|
+
if "connectorId" in data:
|
|
1865
|
+
candidate = data.get("connectorId")
|
|
1866
|
+
if candidate not in (None, ""):
|
|
1867
|
+
try:
|
|
1868
|
+
connector_payload = int(candidate)
|
|
1869
|
+
except (TypeError, ValueError):
|
|
1870
|
+
connector_payload = candidate
|
|
1871
|
+
message_id = uuid.uuid4().hex
|
|
1872
|
+
ocpp_action = "ChangeAvailability"
|
|
1873
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1874
|
+
payload = {"connectorId": connector_payload, "type": availability_type}
|
|
1875
|
+
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
1876
|
+
async_to_sync(ws.send)(msg)
|
|
1877
|
+
requested_at = timezone.now()
|
|
1878
|
+
store.register_pending_call(
|
|
1879
|
+
message_id,
|
|
1880
|
+
{
|
|
1881
|
+
"action": "ChangeAvailability",
|
|
1882
|
+
"charger_id": cid,
|
|
1883
|
+
"connector_id": connector_value,
|
|
1884
|
+
"availability_type": availability_type,
|
|
1885
|
+
"requested_at": requested_at,
|
|
1886
|
+
},
|
|
1887
|
+
)
|
|
1888
|
+
if charger_obj:
|
|
1889
|
+
updates = {
|
|
1890
|
+
"availability_requested_state": availability_type,
|
|
1891
|
+
"availability_requested_at": requested_at,
|
|
1892
|
+
"availability_request_status": "",
|
|
1893
|
+
"availability_request_status_at": None,
|
|
1894
|
+
"availability_request_details": "",
|
|
1895
|
+
}
|
|
1896
|
+
Charger.objects.filter(pk=charger_obj.pk).update(**updates)
|
|
1897
|
+
for field, value in updates.items():
|
|
1898
|
+
setattr(charger_obj, field, value)
|
|
1899
|
+
elif action == "data_transfer":
|
|
1900
|
+
vendor_id = data.get("vendorId")
|
|
1901
|
+
if not isinstance(vendor_id, str) or not vendor_id.strip():
|
|
1902
|
+
return JsonResponse({"detail": "vendorId required"}, status=400)
|
|
1903
|
+
vendor_id = vendor_id.strip()
|
|
1904
|
+
payload: dict[str, object] = {"vendorId": vendor_id}
|
|
1905
|
+
message_identifier = ""
|
|
1906
|
+
if "messageId" in data and data["messageId"] is not None:
|
|
1907
|
+
message_candidate = data["messageId"]
|
|
1908
|
+
if not isinstance(message_candidate, str):
|
|
1909
|
+
return JsonResponse({"detail": "messageId must be a string"}, status=400)
|
|
1910
|
+
message_identifier = message_candidate.strip()
|
|
1911
|
+
if message_identifier:
|
|
1912
|
+
payload["messageId"] = message_identifier
|
|
1913
|
+
if "data" in data:
|
|
1914
|
+
payload["data"] = data["data"]
|
|
1915
|
+
message_id = uuid.uuid4().hex
|
|
1916
|
+
ocpp_action = "DataTransfer"
|
|
1917
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1918
|
+
msg = json.dumps([2, message_id, "DataTransfer", payload])
|
|
1919
|
+
record = DataTransferMessage.objects.create(
|
|
1920
|
+
charger=charger_obj,
|
|
1921
|
+
connector_id=connector_value,
|
|
1922
|
+
direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
|
|
1923
|
+
ocpp_message_id=message_id,
|
|
1924
|
+
vendor_id=vendor_id,
|
|
1925
|
+
message_id=message_identifier,
|
|
1926
|
+
payload=payload,
|
|
1927
|
+
status="Pending",
|
|
1928
|
+
)
|
|
1929
|
+
async_to_sync(ws.send)(msg)
|
|
1930
|
+
store.register_pending_call(
|
|
1931
|
+
message_id,
|
|
1932
|
+
{
|
|
1933
|
+
"action": "DataTransfer",
|
|
1934
|
+
"charger_id": cid,
|
|
1935
|
+
"connector_id": connector_value,
|
|
1936
|
+
"message_pk": record.pk,
|
|
1937
|
+
"log_key": log_key,
|
|
1938
|
+
},
|
|
1939
|
+
)
|
|
1940
|
+
elif action == "reset":
|
|
1941
|
+
tx_obj = store.get_transaction(cid, connector_value)
|
|
1942
|
+
if tx_obj is not None:
|
|
1943
|
+
detail = _(
|
|
1944
|
+
"Reset is blocked while a charging session is active. "
|
|
1945
|
+
"Stop the session first."
|
|
1946
|
+
)
|
|
1947
|
+
return JsonResponse({"detail": detail}, status=409)
|
|
1948
|
+
message_id = uuid.uuid4().hex
|
|
1949
|
+
ocpp_action = "Reset"
|
|
1950
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1951
|
+
msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
|
|
1952
|
+
async_to_sync(ws.send)(msg)
|
|
1953
|
+
store.register_pending_call(
|
|
1954
|
+
message_id,
|
|
1955
|
+
{
|
|
1956
|
+
"action": "Reset",
|
|
1957
|
+
"charger_id": cid,
|
|
1958
|
+
"connector_id": connector_value,
|
|
1959
|
+
"log_key": log_key,
|
|
1960
|
+
"requested_at": timezone.now(),
|
|
1961
|
+
},
|
|
1962
|
+
)
|
|
1963
|
+
elif action == "trigger_message":
|
|
1964
|
+
trigger_target = data.get("target") or data.get("triggerTarget")
|
|
1965
|
+
if not isinstance(trigger_target, str) or not trigger_target.strip():
|
|
1966
|
+
return JsonResponse({"detail": "target required"}, status=400)
|
|
1967
|
+
trigger_target = trigger_target.strip()
|
|
1968
|
+
allowed_targets = {
|
|
1969
|
+
"BootNotification",
|
|
1970
|
+
"DiagnosticsStatusNotification",
|
|
1971
|
+
"FirmwareStatusNotification",
|
|
1972
|
+
"Heartbeat",
|
|
1973
|
+
"MeterValues",
|
|
1974
|
+
"StatusNotification",
|
|
1975
|
+
}
|
|
1976
|
+
if trigger_target not in allowed_targets:
|
|
1977
|
+
return JsonResponse({"detail": "invalid target"}, status=400)
|
|
1978
|
+
payload: dict[str, object] = {"requestedMessage": trigger_target}
|
|
1979
|
+
trigger_connector = None
|
|
1980
|
+
connector_field = data.get("connectorId")
|
|
1981
|
+
if connector_field in (None, ""):
|
|
1982
|
+
connector_field = data.get("connector")
|
|
1983
|
+
if connector_field in (None, "") and connector_value is not None:
|
|
1984
|
+
connector_field = connector_value
|
|
1985
|
+
if connector_field not in (None, ""):
|
|
1986
|
+
try:
|
|
1987
|
+
trigger_connector = int(connector_field)
|
|
1988
|
+
except (TypeError, ValueError):
|
|
1989
|
+
return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
|
|
1990
|
+
if trigger_connector <= 0:
|
|
1991
|
+
return JsonResponse({"detail": "connectorId must be positive"}, status=400)
|
|
1992
|
+
payload["connectorId"] = trigger_connector
|
|
1993
|
+
message_id = uuid.uuid4().hex
|
|
1994
|
+
ocpp_action = "TriggerMessage"
|
|
1995
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1996
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
1997
|
+
async_to_sync(ws.send)(msg)
|
|
1998
|
+
store.register_pending_call(
|
|
1999
|
+
message_id,
|
|
2000
|
+
{
|
|
2001
|
+
"action": "TriggerMessage",
|
|
2002
|
+
"charger_id": cid,
|
|
2003
|
+
"connector_id": connector_value,
|
|
2004
|
+
"log_key": log_key,
|
|
2005
|
+
"trigger_target": trigger_target,
|
|
2006
|
+
"trigger_connector": trigger_connector,
|
|
2007
|
+
"requested_at": timezone.now(),
|
|
2008
|
+
},
|
|
2009
|
+
)
|
|
2010
|
+
else:
|
|
2011
|
+
return JsonResponse({"detail": "unknown action"}, status=400)
|
|
2012
|
+
log_key = store.identity_key(cid, connector_value)
|
|
2013
|
+
if msg is None or message_id is None or ocpp_action is None:
|
|
2014
|
+
return JsonResponse({"detail": "unknown action"}, status=400)
|
|
2015
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
2016
|
+
expected_statuses = expected_statuses or CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
2017
|
+
success, detail, status_code = _evaluate_pending_call_result(
|
|
2018
|
+
message_id,
|
|
2019
|
+
ocpp_action,
|
|
2020
|
+
expected_statuses=expected_statuses,
|
|
2021
|
+
)
|
|
2022
|
+
if not success:
|
|
2023
|
+
return JsonResponse({"detail": detail}, status=status_code or 400)
|
|
2024
|
+
return JsonResponse({"sent": msg})
|