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/models.py
CHANGED
|
@@ -1,640 +1,1417 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
from django.
|
|
9
|
-
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
from core.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
if
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if
|
|
339
|
-
|
|
340
|
-
if
|
|
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
|
-
if
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if
|
|
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
|
-
|
|
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
|
-
def
|
|
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
|
-
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import socket
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.contrib.sites.models import Site
|
|
10
|
+
from django.db import models
|
|
11
|
+
from django.db.models import Q
|
|
12
|
+
from django.core.exceptions import ValidationError
|
|
13
|
+
from django.urls import reverse
|
|
14
|
+
from django.utils.translation import gettext_lazy as _
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
from asgiref.sync import async_to_sync
|
|
18
|
+
|
|
19
|
+
from core.entity import Entity, EntityManager
|
|
20
|
+
from nodes.models import Node
|
|
21
|
+
|
|
22
|
+
from core.models import (
|
|
23
|
+
EnergyAccount,
|
|
24
|
+
EnergyTariff,
|
|
25
|
+
Reference,
|
|
26
|
+
RFID as CoreRFID,
|
|
27
|
+
ElectricVehicle as CoreElectricVehicle,
|
|
28
|
+
Brand as CoreBrand,
|
|
29
|
+
EVModel as CoreEVModel,
|
|
30
|
+
SecurityGroup,
|
|
31
|
+
)
|
|
32
|
+
from . import store
|
|
33
|
+
from .reference_utils import url_targets_local_loopback
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Location(Entity):
|
|
37
|
+
"""Physical location shared by chargers."""
|
|
38
|
+
|
|
39
|
+
name = models.CharField(max_length=200)
|
|
40
|
+
latitude = models.DecimalField(
|
|
41
|
+
max_digits=9, decimal_places=6, null=True, blank=True
|
|
42
|
+
)
|
|
43
|
+
longitude = models.DecimalField(
|
|
44
|
+
max_digits=9, decimal_places=6, null=True, blank=True
|
|
45
|
+
)
|
|
46
|
+
zone = models.CharField(
|
|
47
|
+
max_length=3,
|
|
48
|
+
choices=EnergyTariff.Zone.choices,
|
|
49
|
+
blank=True,
|
|
50
|
+
null=True,
|
|
51
|
+
help_text=_("CFE climate zone used to select matching energy tariffs."),
|
|
52
|
+
)
|
|
53
|
+
contract_type = models.CharField(
|
|
54
|
+
max_length=16,
|
|
55
|
+
choices=EnergyTariff.ContractType.choices,
|
|
56
|
+
blank=True,
|
|
57
|
+
null=True,
|
|
58
|
+
help_text=_(
|
|
59
|
+
"CFE service contract type required to match energy tariff pricing."
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
64
|
+
return self.name
|
|
65
|
+
|
|
66
|
+
class Meta:
|
|
67
|
+
verbose_name = _("Charge Location")
|
|
68
|
+
verbose_name_plural = _("Charge Locations")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Charger(Entity):
|
|
72
|
+
"""Known charge point."""
|
|
73
|
+
|
|
74
|
+
_PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
|
|
75
|
+
_AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
|
76
|
+
|
|
77
|
+
OPERATIVE_STATUSES = {
|
|
78
|
+
"Available",
|
|
79
|
+
"Preparing",
|
|
80
|
+
"Charging",
|
|
81
|
+
"SuspendedEV",
|
|
82
|
+
"SuspendedEVSE",
|
|
83
|
+
"Finishing",
|
|
84
|
+
"Reserved",
|
|
85
|
+
}
|
|
86
|
+
INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
|
|
87
|
+
|
|
88
|
+
charger_id = models.CharField(
|
|
89
|
+
_("Serial Number"),
|
|
90
|
+
max_length=100,
|
|
91
|
+
help_text="Unique identifier reported by the charger.",
|
|
92
|
+
)
|
|
93
|
+
display_name = models.CharField(
|
|
94
|
+
_("Display Name"),
|
|
95
|
+
max_length=200,
|
|
96
|
+
blank=True,
|
|
97
|
+
help_text="Optional friendly name shown on public pages.",
|
|
98
|
+
)
|
|
99
|
+
connector_id = models.PositiveIntegerField(
|
|
100
|
+
_("Connector ID"),
|
|
101
|
+
blank=True,
|
|
102
|
+
null=True,
|
|
103
|
+
help_text="Optional connector identifier for multi-connector chargers.",
|
|
104
|
+
)
|
|
105
|
+
public_display = models.BooleanField(
|
|
106
|
+
_("Public"),
|
|
107
|
+
default=True,
|
|
108
|
+
help_text="Display this charger on the public status dashboard.",
|
|
109
|
+
)
|
|
110
|
+
language = models.CharField(
|
|
111
|
+
_("Language"),
|
|
112
|
+
max_length=12,
|
|
113
|
+
choices=settings.LANGUAGES,
|
|
114
|
+
default="es",
|
|
115
|
+
help_text=_("Preferred language for the public landing page."),
|
|
116
|
+
)
|
|
117
|
+
require_rfid = models.BooleanField(
|
|
118
|
+
_("Require RFID Authorization"),
|
|
119
|
+
default=False,
|
|
120
|
+
help_text="Require a valid RFID before starting a charging session.",
|
|
121
|
+
)
|
|
122
|
+
firmware_status = models.CharField(
|
|
123
|
+
_("Status"),
|
|
124
|
+
max_length=32,
|
|
125
|
+
blank=True,
|
|
126
|
+
default="",
|
|
127
|
+
help_text="Latest firmware status reported by the charger.",
|
|
128
|
+
)
|
|
129
|
+
firmware_status_info = models.CharField(
|
|
130
|
+
_("Status Details"),
|
|
131
|
+
max_length=255,
|
|
132
|
+
blank=True,
|
|
133
|
+
default="",
|
|
134
|
+
help_text="Additional information supplied with the firmware status.",
|
|
135
|
+
)
|
|
136
|
+
firmware_timestamp = models.DateTimeField(
|
|
137
|
+
_("Status Timestamp"),
|
|
138
|
+
null=True,
|
|
139
|
+
blank=True,
|
|
140
|
+
help_text="When the charger reported the current firmware status.",
|
|
141
|
+
)
|
|
142
|
+
last_heartbeat = models.DateTimeField(null=True, blank=True)
|
|
143
|
+
last_meter_values = models.JSONField(default=dict, blank=True)
|
|
144
|
+
last_status = models.CharField(max_length=64, blank=True)
|
|
145
|
+
last_error_code = models.CharField(max_length=64, blank=True)
|
|
146
|
+
last_status_vendor_info = models.JSONField(null=True, blank=True)
|
|
147
|
+
last_status_timestamp = models.DateTimeField(null=True, blank=True)
|
|
148
|
+
availability_state = models.CharField(
|
|
149
|
+
_("State"),
|
|
150
|
+
max_length=16,
|
|
151
|
+
blank=True,
|
|
152
|
+
default="",
|
|
153
|
+
help_text=(
|
|
154
|
+
"Current availability reported by the charger "
|
|
155
|
+
"(Operative/Inoperative)."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
availability_state_updated_at = models.DateTimeField(
|
|
159
|
+
_("State Updated At"),
|
|
160
|
+
null=True,
|
|
161
|
+
blank=True,
|
|
162
|
+
help_text="When the current availability state became effective.",
|
|
163
|
+
)
|
|
164
|
+
availability_requested_state = models.CharField(
|
|
165
|
+
_("Requested State"),
|
|
166
|
+
max_length=16,
|
|
167
|
+
blank=True,
|
|
168
|
+
default="",
|
|
169
|
+
help_text="Last availability state requested via ChangeAvailability.",
|
|
170
|
+
)
|
|
171
|
+
availability_requested_at = models.DateTimeField(
|
|
172
|
+
_("Requested At"),
|
|
173
|
+
null=True,
|
|
174
|
+
blank=True,
|
|
175
|
+
help_text="When the last ChangeAvailability request was sent.",
|
|
176
|
+
)
|
|
177
|
+
availability_request_status = models.CharField(
|
|
178
|
+
_("Request Status"),
|
|
179
|
+
max_length=16,
|
|
180
|
+
blank=True,
|
|
181
|
+
default="",
|
|
182
|
+
help_text=(
|
|
183
|
+
"Latest response status for ChangeAvailability "
|
|
184
|
+
"(Accepted/Rejected/Scheduled)."
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
availability_request_status_at = models.DateTimeField(
|
|
188
|
+
_("Request Status At"),
|
|
189
|
+
null=True,
|
|
190
|
+
blank=True,
|
|
191
|
+
help_text="When the last ChangeAvailability response was received.",
|
|
192
|
+
)
|
|
193
|
+
availability_request_details = models.CharField(
|
|
194
|
+
_("Request Details"),
|
|
195
|
+
max_length=255,
|
|
196
|
+
blank=True,
|
|
197
|
+
default="",
|
|
198
|
+
help_text="Additional details from the last ChangeAvailability response.",
|
|
199
|
+
)
|
|
200
|
+
temperature = models.DecimalField(
|
|
201
|
+
max_digits=5, decimal_places=1, null=True, blank=True
|
|
202
|
+
)
|
|
203
|
+
temperature_unit = models.CharField(max_length=16, blank=True)
|
|
204
|
+
diagnostics_status = models.CharField(
|
|
205
|
+
max_length=32,
|
|
206
|
+
null=True,
|
|
207
|
+
blank=True,
|
|
208
|
+
help_text="Most recent diagnostics status reported by the charger.",
|
|
209
|
+
)
|
|
210
|
+
diagnostics_timestamp = models.DateTimeField(
|
|
211
|
+
null=True,
|
|
212
|
+
blank=True,
|
|
213
|
+
help_text="Timestamp associated with the latest diagnostics status.",
|
|
214
|
+
)
|
|
215
|
+
diagnostics_location = models.CharField(
|
|
216
|
+
max_length=255,
|
|
217
|
+
null=True,
|
|
218
|
+
blank=True,
|
|
219
|
+
help_text="Location or URI reported for the latest diagnostics upload.",
|
|
220
|
+
)
|
|
221
|
+
reference = models.OneToOneField(
|
|
222
|
+
Reference, null=True, blank=True, on_delete=models.SET_NULL
|
|
223
|
+
)
|
|
224
|
+
location = models.ForeignKey(
|
|
225
|
+
Location,
|
|
226
|
+
null=True,
|
|
227
|
+
blank=True,
|
|
228
|
+
on_delete=models.SET_NULL,
|
|
229
|
+
related_name="chargers",
|
|
230
|
+
)
|
|
231
|
+
last_path = models.CharField(max_length=255, blank=True)
|
|
232
|
+
configuration = models.ForeignKey(
|
|
233
|
+
"ChargerConfiguration",
|
|
234
|
+
null=True,
|
|
235
|
+
blank=True,
|
|
236
|
+
on_delete=models.SET_NULL,
|
|
237
|
+
related_name="chargers",
|
|
238
|
+
help_text=_(
|
|
239
|
+
"Latest GetConfiguration response received from this charge point."
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
node_origin = models.ForeignKey(
|
|
243
|
+
"nodes.Node",
|
|
244
|
+
on_delete=models.SET_NULL,
|
|
245
|
+
null=True,
|
|
246
|
+
blank=True,
|
|
247
|
+
related_name="origin_chargers",
|
|
248
|
+
)
|
|
249
|
+
manager_node = models.ForeignKey(
|
|
250
|
+
"nodes.Node",
|
|
251
|
+
on_delete=models.SET_NULL,
|
|
252
|
+
null=True,
|
|
253
|
+
blank=True,
|
|
254
|
+
related_name="managed_chargers",
|
|
255
|
+
)
|
|
256
|
+
forwarded_to = models.ForeignKey(
|
|
257
|
+
"nodes.Node",
|
|
258
|
+
on_delete=models.SET_NULL,
|
|
259
|
+
null=True,
|
|
260
|
+
blank=True,
|
|
261
|
+
related_name="forwarded_chargers",
|
|
262
|
+
help_text=_("Remote node receiving forwarded transactions."),
|
|
263
|
+
)
|
|
264
|
+
forwarding_watermark = models.DateTimeField(
|
|
265
|
+
null=True,
|
|
266
|
+
blank=True,
|
|
267
|
+
help_text=_("Timestamp of the last forwarded transaction."),
|
|
268
|
+
)
|
|
269
|
+
allow_remote = models.BooleanField(default=False)
|
|
270
|
+
export_transactions = models.BooleanField(default=False)
|
|
271
|
+
last_online_at = models.DateTimeField(null=True, blank=True)
|
|
272
|
+
owner_users = models.ManyToManyField(
|
|
273
|
+
settings.AUTH_USER_MODEL,
|
|
274
|
+
blank=True,
|
|
275
|
+
related_name="owned_chargers",
|
|
276
|
+
help_text=_("Users who can view this charge point."),
|
|
277
|
+
)
|
|
278
|
+
owner_groups = models.ManyToManyField(
|
|
279
|
+
SecurityGroup,
|
|
280
|
+
blank=True,
|
|
281
|
+
related_name="owned_chargers",
|
|
282
|
+
help_text=_("Security groups that can view this charge point."),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
286
|
+
return self.charger_id
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def visible_for_user(cls, user):
|
|
290
|
+
"""Return chargers marked for display that the user may view."""
|
|
291
|
+
|
|
292
|
+
qs = cls.objects.filter(public_display=True)
|
|
293
|
+
if getattr(user, "is_superuser", False):
|
|
294
|
+
return qs
|
|
295
|
+
if not getattr(user, "is_authenticated", False):
|
|
296
|
+
return qs.filter(
|
|
297
|
+
owner_users__isnull=True, owner_groups__isnull=True
|
|
298
|
+
).distinct()
|
|
299
|
+
group_ids = list(user.groups.values_list("pk", flat=True))
|
|
300
|
+
visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
|
|
301
|
+
owner_users=user
|
|
302
|
+
)
|
|
303
|
+
if group_ids:
|
|
304
|
+
visibility |= Q(owner_groups__pk__in=group_ids)
|
|
305
|
+
return qs.filter(visibility).distinct()
|
|
306
|
+
|
|
307
|
+
def has_owner_scope(self) -> bool:
|
|
308
|
+
"""Return ``True`` when owner restrictions are defined."""
|
|
309
|
+
|
|
310
|
+
return self.owner_users.exists() or self.owner_groups.exists()
|
|
311
|
+
|
|
312
|
+
def is_visible_to(self, user) -> bool:
|
|
313
|
+
"""Return ``True`` when ``user`` may view this charger."""
|
|
314
|
+
|
|
315
|
+
if getattr(user, "is_superuser", False):
|
|
316
|
+
return True
|
|
317
|
+
if not self.has_owner_scope():
|
|
318
|
+
return True
|
|
319
|
+
if not getattr(user, "is_authenticated", False):
|
|
320
|
+
return False
|
|
321
|
+
if self.owner_users.filter(pk=user.pk).exists():
|
|
322
|
+
return True
|
|
323
|
+
user_group_ids = user.groups.values_list("pk", flat=True)
|
|
324
|
+
return self.owner_groups.filter(pk__in=user_group_ids).exists()
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def is_local(self) -> bool:
|
|
328
|
+
"""Return ``True`` when this charger originates from the local node."""
|
|
329
|
+
|
|
330
|
+
local = Node.get_local()
|
|
331
|
+
if not local:
|
|
332
|
+
return False
|
|
333
|
+
if self.node_origin_id is None:
|
|
334
|
+
return True
|
|
335
|
+
return self.node_origin_id == local.pk
|
|
336
|
+
|
|
337
|
+
def save(self, *args, **kwargs):
|
|
338
|
+
if self.node_origin_id is None:
|
|
339
|
+
local = Node.get_local()
|
|
340
|
+
if local:
|
|
341
|
+
self.node_origin = local
|
|
342
|
+
super().save(*args, **kwargs)
|
|
343
|
+
|
|
344
|
+
class Meta:
|
|
345
|
+
verbose_name = _("Charge Point")
|
|
346
|
+
verbose_name_plural = _("Charge Points")
|
|
347
|
+
constraints = [
|
|
348
|
+
models.UniqueConstraint(
|
|
349
|
+
fields=("charger_id", "connector_id"),
|
|
350
|
+
condition=models.Q(connector_id__isnull=False),
|
|
351
|
+
name="charger_connector_unique",
|
|
352
|
+
),
|
|
353
|
+
models.UniqueConstraint(
|
|
354
|
+
fields=("charger_id",),
|
|
355
|
+
condition=models.Q(connector_id__isnull=True),
|
|
356
|
+
name="charger_unique_without_connector",
|
|
357
|
+
),
|
|
358
|
+
]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def normalize_serial(cls, value: str | None) -> str:
|
|
363
|
+
"""Return ``value`` trimmed for consistent comparisons."""
|
|
364
|
+
|
|
365
|
+
if value is None:
|
|
366
|
+
return ""
|
|
367
|
+
return str(value).strip()
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def is_placeholder_serial(cls, value: str | None) -> bool:
|
|
371
|
+
"""Return ``True`` when ``value`` matches the placeholder pattern."""
|
|
372
|
+
|
|
373
|
+
normalized = cls.normalize_serial(value)
|
|
374
|
+
return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def validate_serial(cls, value: str | None) -> str:
|
|
378
|
+
"""Return a normalized serial number or raise ``ValidationError``."""
|
|
379
|
+
|
|
380
|
+
normalized = cls.normalize_serial(value)
|
|
381
|
+
if not normalized:
|
|
382
|
+
raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
|
|
383
|
+
if cls.is_placeholder_serial(normalized):
|
|
384
|
+
raise ValidationError(
|
|
385
|
+
{
|
|
386
|
+
"charger_id": _(
|
|
387
|
+
"Serial Number placeholder values such as <charger_id> are not allowed."
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
return normalized
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def sanitize_auto_location_name(cls, value: str) -> str:
|
|
395
|
+
"""Return a location name containing only safe characters."""
|
|
396
|
+
|
|
397
|
+
sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
|
|
398
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
|
399
|
+
if not sanitized:
|
|
400
|
+
return "Charger"
|
|
401
|
+
return sanitized
|
|
402
|
+
|
|
403
|
+
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
404
|
+
|
|
405
|
+
def identity_tuple(self) -> tuple[str, int | None]:
|
|
406
|
+
"""Return the canonical identity for this charger."""
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
self.charger_id,
|
|
410
|
+
self.connector_id if self.connector_id is not None else None,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
@classmethod
|
|
414
|
+
def connector_slug_from_value(cls, connector: int | None) -> str:
|
|
415
|
+
"""Return the slug used in URLs for the given connector."""
|
|
416
|
+
|
|
417
|
+
return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
|
|
418
|
+
|
|
419
|
+
@classmethod
|
|
420
|
+
def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
|
|
421
|
+
"""Return the connector integer represented by ``slug``."""
|
|
422
|
+
|
|
423
|
+
if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
|
|
424
|
+
return None
|
|
425
|
+
if isinstance(slug, int):
|
|
426
|
+
return slug
|
|
427
|
+
try:
|
|
428
|
+
return int(str(slug))
|
|
429
|
+
except (TypeError, ValueError) as exc:
|
|
430
|
+
raise ValueError(f"Invalid connector slug: {slug}") from exc
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def availability_state_from_status(cls, status: str) -> str | None:
|
|
434
|
+
"""Return the availability state implied by a status notification."""
|
|
435
|
+
|
|
436
|
+
normalized = (status or "").strip()
|
|
437
|
+
if not normalized:
|
|
438
|
+
return None
|
|
439
|
+
if normalized in cls.INOPERATIVE_STATUSES:
|
|
440
|
+
return "Inoperative"
|
|
441
|
+
if normalized in cls.OPERATIVE_STATUSES:
|
|
442
|
+
return "Operative"
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def connector_slug(self) -> str:
|
|
447
|
+
"""Return the slug representing this charger's connector."""
|
|
448
|
+
|
|
449
|
+
return type(self).connector_slug_from_value(self.connector_id)
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def connector_label(self) -> str:
|
|
453
|
+
"""Return a short human readable label for this connector."""
|
|
454
|
+
|
|
455
|
+
if self.connector_id is None:
|
|
456
|
+
return _("All Connectors")
|
|
457
|
+
|
|
458
|
+
special_labels = {
|
|
459
|
+
1: _("Connector 1 (Left)"),
|
|
460
|
+
2: _("Connector 2 (Right)"),
|
|
461
|
+
}
|
|
462
|
+
if self.connector_id in special_labels:
|
|
463
|
+
return special_labels[self.connector_id]
|
|
464
|
+
|
|
465
|
+
return _("Connector %(number)s") % {"number": self.connector_id}
|
|
466
|
+
|
|
467
|
+
def identity_slug(self) -> str:
|
|
468
|
+
"""Return a unique slug for this charger identity."""
|
|
469
|
+
|
|
470
|
+
serial, connector = self.identity_tuple()
|
|
471
|
+
return f"{serial}#{type(self).connector_slug_from_value(connector)}"
|
|
472
|
+
|
|
473
|
+
def get_absolute_url(self):
|
|
474
|
+
serial, connector = self.identity_tuple()
|
|
475
|
+
connector_slug = type(self).connector_slug_from_value(connector)
|
|
476
|
+
if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
|
|
477
|
+
return reverse("charger-page", args=[serial])
|
|
478
|
+
return reverse("charger-page-connector", args=[serial, connector_slug])
|
|
479
|
+
|
|
480
|
+
def _fallback_domain(self) -> str:
|
|
481
|
+
"""Return a best-effort hostname when the Sites framework is unset."""
|
|
482
|
+
|
|
483
|
+
fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
|
|
484
|
+
settings, "DEFAULT_DOMAIN", ""
|
|
485
|
+
)
|
|
486
|
+
if fallback:
|
|
487
|
+
return fallback.strip()
|
|
488
|
+
|
|
489
|
+
for host in getattr(settings, "ALLOWED_HOSTS", []):
|
|
490
|
+
if not isinstance(host, str):
|
|
491
|
+
continue
|
|
492
|
+
host = host.strip()
|
|
493
|
+
if not host or host.startswith("*") or "/" in host:
|
|
494
|
+
continue
|
|
495
|
+
return host
|
|
496
|
+
|
|
497
|
+
return socket.gethostname() or "localhost"
|
|
498
|
+
|
|
499
|
+
def _full_url(self) -> str:
|
|
500
|
+
"""Return absolute URL for the charger landing page."""
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
domain = Site.objects.get_current().domain.strip()
|
|
504
|
+
except Site.DoesNotExist:
|
|
505
|
+
domain = ""
|
|
506
|
+
|
|
507
|
+
if not domain:
|
|
508
|
+
domain = self._fallback_domain()
|
|
509
|
+
|
|
510
|
+
scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
|
|
511
|
+
return f"{scheme}://{domain}{self.get_absolute_url()}"
|
|
512
|
+
|
|
513
|
+
def clean(self):
|
|
514
|
+
super().clean()
|
|
515
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
516
|
+
|
|
517
|
+
def save(self, *args, **kwargs):
|
|
518
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
519
|
+
update_fields = kwargs.get("update_fields")
|
|
520
|
+
update_list = list(update_fields) if update_fields is not None else None
|
|
521
|
+
if not self.manager_node_id:
|
|
522
|
+
local_node = Node.get_local()
|
|
523
|
+
if local_node:
|
|
524
|
+
self.manager_node = local_node
|
|
525
|
+
if update_list is not None and "manager_node" not in update_list:
|
|
526
|
+
update_list.append("manager_node")
|
|
527
|
+
if not self.location_id:
|
|
528
|
+
existing = (
|
|
529
|
+
type(self)
|
|
530
|
+
.objects.filter(charger_id=self.charger_id, location__isnull=False)
|
|
531
|
+
.exclude(pk=self.pk)
|
|
532
|
+
.select_related("location")
|
|
533
|
+
.first()
|
|
534
|
+
)
|
|
535
|
+
if existing:
|
|
536
|
+
self.location = existing.location
|
|
537
|
+
else:
|
|
538
|
+
auto_name = type(self).sanitize_auto_location_name(self.charger_id)
|
|
539
|
+
location, _ = Location.objects.get_or_create(name=auto_name)
|
|
540
|
+
self.location = location
|
|
541
|
+
if update_list is not None and "location" not in update_list:
|
|
542
|
+
update_list.append("location")
|
|
543
|
+
if update_list is not None:
|
|
544
|
+
kwargs["update_fields"] = update_list
|
|
545
|
+
super().save(*args, **kwargs)
|
|
546
|
+
ref_value = self._full_url()
|
|
547
|
+
if url_targets_local_loopback(ref_value):
|
|
548
|
+
return
|
|
549
|
+
if not self.reference:
|
|
550
|
+
self.reference = Reference.objects.create(
|
|
551
|
+
value=ref_value, alt_text=self.charger_id
|
|
552
|
+
)
|
|
553
|
+
super().save(update_fields=["reference"])
|
|
554
|
+
elif self.reference.value != ref_value:
|
|
555
|
+
Reference.objects.filter(pk=self.reference_id).update(
|
|
556
|
+
value=ref_value, alt_text=self.charger_id
|
|
557
|
+
)
|
|
558
|
+
self.reference.value = ref_value
|
|
559
|
+
self.reference.alt_text = self.charger_id
|
|
560
|
+
|
|
561
|
+
def refresh_manager_node(self, node: Node | None = None) -> Node | None:
|
|
562
|
+
"""Ensure ``manager_node`` matches the provided or local node."""
|
|
563
|
+
|
|
564
|
+
node = node or Node.get_local()
|
|
565
|
+
if not node:
|
|
566
|
+
return None
|
|
567
|
+
if self.pk is None:
|
|
568
|
+
self.manager_node = node
|
|
569
|
+
return node
|
|
570
|
+
if self.manager_node_id != node.pk:
|
|
571
|
+
type(self).objects.filter(pk=self.pk).update(manager_node=node)
|
|
572
|
+
self.manager_node = node
|
|
573
|
+
return node
|
|
574
|
+
|
|
575
|
+
@property
|
|
576
|
+
def name(self) -> str:
|
|
577
|
+
if self.location:
|
|
578
|
+
if self.connector_id is not None:
|
|
579
|
+
return f"{self.location.name} #{self.connector_id}"
|
|
580
|
+
return self.location.name
|
|
581
|
+
return ""
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def latitude(self):
|
|
585
|
+
return self.location.latitude if self.location else None
|
|
586
|
+
|
|
587
|
+
@property
|
|
588
|
+
def longitude(self):
|
|
589
|
+
return self.location.longitude if self.location else None
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def total_kw(self) -> float:
|
|
593
|
+
"""Return total energy delivered by this charger in kW."""
|
|
594
|
+
from . import store
|
|
595
|
+
|
|
596
|
+
total = 0.0
|
|
597
|
+
for charger in self._target_chargers():
|
|
598
|
+
total += charger._total_kw_single(store)
|
|
599
|
+
return total
|
|
600
|
+
|
|
601
|
+
def _store_keys(self) -> list[str]:
|
|
602
|
+
"""Return keys used for store lookups with fallbacks."""
|
|
603
|
+
|
|
604
|
+
from . import store
|
|
605
|
+
|
|
606
|
+
base = self.charger_id
|
|
607
|
+
connector = self.connector_id
|
|
608
|
+
keys: list[str] = []
|
|
609
|
+
keys.append(store.identity_key(base, connector))
|
|
610
|
+
if connector is not None:
|
|
611
|
+
keys.append(store.identity_key(base, None))
|
|
612
|
+
keys.append(store.pending_key(base))
|
|
613
|
+
keys.append(base)
|
|
614
|
+
seen: set[str] = set()
|
|
615
|
+
deduped: list[str] = []
|
|
616
|
+
for key in keys:
|
|
617
|
+
if key not in seen:
|
|
618
|
+
seen.add(key)
|
|
619
|
+
deduped.append(key)
|
|
620
|
+
return deduped
|
|
621
|
+
|
|
622
|
+
def _target_chargers(self):
|
|
623
|
+
"""Return chargers contributing to aggregate operations."""
|
|
624
|
+
|
|
625
|
+
qs = type(self).objects.filter(charger_id=self.charger_id)
|
|
626
|
+
if self.connector_id is None:
|
|
627
|
+
return qs
|
|
628
|
+
return qs.filter(pk=self.pk)
|
|
629
|
+
|
|
630
|
+
def total_kw_for_range(
|
|
631
|
+
self,
|
|
632
|
+
start=None,
|
|
633
|
+
end=None,
|
|
634
|
+
) -> float:
|
|
635
|
+
"""Return total energy delivered within ``start``/``end`` window."""
|
|
636
|
+
|
|
637
|
+
from . import store
|
|
638
|
+
|
|
639
|
+
total = 0.0
|
|
640
|
+
for charger in self._target_chargers():
|
|
641
|
+
total += charger._total_kw_range_single(store, start, end)
|
|
642
|
+
return total
|
|
643
|
+
|
|
644
|
+
def _total_kw_single(self, store_module) -> float:
|
|
645
|
+
"""Return total kW for this specific charger identity."""
|
|
646
|
+
|
|
647
|
+
tx_active = None
|
|
648
|
+
if self.connector_id is not None:
|
|
649
|
+
tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
|
|
650
|
+
qs = self.transactions.all()
|
|
651
|
+
if tx_active and tx_active.pk is not None:
|
|
652
|
+
qs = qs.exclude(pk=tx_active.pk)
|
|
653
|
+
total = 0.0
|
|
654
|
+
for tx in qs:
|
|
655
|
+
kw = tx.kw
|
|
656
|
+
if kw:
|
|
657
|
+
total += kw
|
|
658
|
+
if tx_active:
|
|
659
|
+
kw = tx_active.kw
|
|
660
|
+
if kw:
|
|
661
|
+
total += kw
|
|
662
|
+
return total
|
|
663
|
+
|
|
664
|
+
def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
|
|
665
|
+
"""Return total kW for a date range for this charger."""
|
|
666
|
+
|
|
667
|
+
tx_active = None
|
|
668
|
+
if self.connector_id is not None:
|
|
669
|
+
tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
|
|
670
|
+
|
|
671
|
+
qs = self.transactions.all()
|
|
672
|
+
if start is not None:
|
|
673
|
+
qs = qs.filter(start_time__gte=start)
|
|
674
|
+
if end is not None:
|
|
675
|
+
qs = qs.filter(start_time__lt=end)
|
|
676
|
+
if tx_active and tx_active.pk is not None:
|
|
677
|
+
qs = qs.exclude(pk=tx_active.pk)
|
|
678
|
+
|
|
679
|
+
total = 0.0
|
|
680
|
+
for tx in qs:
|
|
681
|
+
kw = tx.kw
|
|
682
|
+
if kw:
|
|
683
|
+
total += kw
|
|
684
|
+
|
|
685
|
+
if tx_active:
|
|
686
|
+
start_time = getattr(tx_active, "start_time", None)
|
|
687
|
+
include = True
|
|
688
|
+
if start is not None and start_time and start_time < start:
|
|
689
|
+
include = False
|
|
690
|
+
if end is not None and start_time and start_time >= end:
|
|
691
|
+
include = False
|
|
692
|
+
if include:
|
|
693
|
+
kw = tx_active.kw
|
|
694
|
+
if kw:
|
|
695
|
+
total += kw
|
|
696
|
+
return total
|
|
697
|
+
|
|
698
|
+
def purge(self):
|
|
699
|
+
from . import store
|
|
700
|
+
|
|
701
|
+
for charger in self._target_chargers():
|
|
702
|
+
charger.transactions.all().delete()
|
|
703
|
+
charger.meter_values.all().delete()
|
|
704
|
+
for key in charger._store_keys():
|
|
705
|
+
store.clear_log(key, log_type="charger")
|
|
706
|
+
store.transactions.pop(key, None)
|
|
707
|
+
store.history.pop(key, None)
|
|
708
|
+
|
|
709
|
+
def delete(self, *args, **kwargs):
|
|
710
|
+
from django.db.models.deletion import ProtectedError
|
|
711
|
+
from . import store
|
|
712
|
+
|
|
713
|
+
for charger in self._target_chargers():
|
|
714
|
+
has_data = (
|
|
715
|
+
charger.transactions.exists()
|
|
716
|
+
or charger.meter_values.exists()
|
|
717
|
+
or any(
|
|
718
|
+
store.get_logs(key, log_type="charger")
|
|
719
|
+
for key in charger._store_keys()
|
|
720
|
+
)
|
|
721
|
+
or any(store.transactions.get(key) for key in charger._store_keys())
|
|
722
|
+
or any(store.history.get(key) for key in charger._store_keys())
|
|
723
|
+
)
|
|
724
|
+
if has_data:
|
|
725
|
+
raise ProtectedError("Purge data before deleting charger.", [])
|
|
726
|
+
super().delete(*args, **kwargs)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class ChargerConfiguration(models.Model):
|
|
730
|
+
"""Persisted configuration package returned by a charge point."""
|
|
731
|
+
|
|
732
|
+
charger_identifier = models.CharField(_("Serial Number"), max_length=100)
|
|
733
|
+
connector_id = models.PositiveIntegerField(
|
|
734
|
+
_("Connector ID"),
|
|
735
|
+
null=True,
|
|
736
|
+
blank=True,
|
|
737
|
+
help_text=_("Connector that returned this configuration (if specified)."),
|
|
738
|
+
)
|
|
739
|
+
configuration_keys = models.JSONField(
|
|
740
|
+
default=list,
|
|
741
|
+
blank=True,
|
|
742
|
+
help_text=_("Entries from the configurationKey list."),
|
|
743
|
+
)
|
|
744
|
+
unknown_keys = models.JSONField(
|
|
745
|
+
default=list,
|
|
746
|
+
blank=True,
|
|
747
|
+
help_text=_("Keys returned in the unknownKey list."),
|
|
748
|
+
)
|
|
749
|
+
evcs_snapshot_at = models.DateTimeField(
|
|
750
|
+
_("EVCS snapshot at"),
|
|
751
|
+
null=True,
|
|
752
|
+
blank=True,
|
|
753
|
+
help_text=_(
|
|
754
|
+
"Timestamp when this configuration was received from the charge point."
|
|
755
|
+
),
|
|
756
|
+
)
|
|
757
|
+
raw_payload = models.JSONField(
|
|
758
|
+
default=dict,
|
|
759
|
+
blank=True,
|
|
760
|
+
help_text=_("Raw payload returned by the GetConfiguration call."),
|
|
761
|
+
)
|
|
762
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
763
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
764
|
+
|
|
765
|
+
class Meta:
|
|
766
|
+
ordering = ["-created_at"]
|
|
767
|
+
verbose_name = _("CP Configuration")
|
|
768
|
+
verbose_name_plural = _("CP Configurations")
|
|
769
|
+
|
|
770
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
771
|
+
connector = (
|
|
772
|
+
_("connector %(number)s") % {"number": self.connector_id}
|
|
773
|
+
if self.connector_id is not None
|
|
774
|
+
else _("all connectors")
|
|
775
|
+
)
|
|
776
|
+
return _("%(serial)s configuration (%(connector)s)") % {
|
|
777
|
+
"serial": self.charger_identifier,
|
|
778
|
+
"connector": connector,
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
class Transaction(Entity):
|
|
783
|
+
"""Charging session data stored for each charger."""
|
|
784
|
+
|
|
785
|
+
charger = models.ForeignKey(
|
|
786
|
+
Charger, on_delete=models.CASCADE, related_name="transactions", null=True
|
|
787
|
+
)
|
|
788
|
+
account = models.ForeignKey(
|
|
789
|
+
EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
|
|
790
|
+
)
|
|
791
|
+
rfid = models.CharField(
|
|
792
|
+
max_length=20,
|
|
793
|
+
blank=True,
|
|
794
|
+
verbose_name=_("RFID"),
|
|
795
|
+
)
|
|
796
|
+
vid = models.CharField(
|
|
797
|
+
max_length=64,
|
|
798
|
+
blank=True,
|
|
799
|
+
default="",
|
|
800
|
+
verbose_name=_("VID"),
|
|
801
|
+
help_text=_("Vehicle identifier reported by the charger."),
|
|
802
|
+
)
|
|
803
|
+
vin = models.CharField(
|
|
804
|
+
max_length=17,
|
|
805
|
+
blank=True,
|
|
806
|
+
help_text=_("Deprecated. Use VID instead."),
|
|
807
|
+
)
|
|
808
|
+
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
809
|
+
meter_start = models.IntegerField(null=True, blank=True)
|
|
810
|
+
meter_stop = models.IntegerField(null=True, blank=True)
|
|
811
|
+
voltage_start = models.DecimalField(
|
|
812
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
813
|
+
)
|
|
814
|
+
voltage_stop = models.DecimalField(
|
|
815
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
816
|
+
)
|
|
817
|
+
current_import_start = models.DecimalField(
|
|
818
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
819
|
+
)
|
|
820
|
+
current_import_stop = models.DecimalField(
|
|
821
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
822
|
+
)
|
|
823
|
+
current_offered_start = models.DecimalField(
|
|
824
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
825
|
+
)
|
|
826
|
+
current_offered_stop = models.DecimalField(
|
|
827
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
828
|
+
)
|
|
829
|
+
temperature_start = models.DecimalField(
|
|
830
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
831
|
+
)
|
|
832
|
+
temperature_stop = models.DecimalField(
|
|
833
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
834
|
+
)
|
|
835
|
+
soc_start = models.DecimalField(
|
|
836
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
837
|
+
)
|
|
838
|
+
soc_stop = models.DecimalField(
|
|
839
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
840
|
+
)
|
|
841
|
+
start_time = models.DateTimeField()
|
|
842
|
+
stop_time = models.DateTimeField(null=True, blank=True)
|
|
843
|
+
received_start_time = models.DateTimeField(null=True, blank=True)
|
|
844
|
+
received_stop_time = models.DateTimeField(null=True, blank=True)
|
|
845
|
+
|
|
846
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
847
|
+
return f"{self.charger}:{self.pk}"
|
|
848
|
+
|
|
849
|
+
class Meta:
|
|
850
|
+
verbose_name = _("Transaction")
|
|
851
|
+
verbose_name_plural = _("CP Transactions")
|
|
852
|
+
|
|
853
|
+
@property
|
|
854
|
+
def vehicle_identifier(self) -> str:
|
|
855
|
+
"""Return the preferred vehicle identifier for this transaction."""
|
|
856
|
+
|
|
857
|
+
vid = (self.vid or "").strip()
|
|
858
|
+
if vid:
|
|
859
|
+
return vid
|
|
860
|
+
|
|
861
|
+
return (self.vin or "").strip()
|
|
862
|
+
|
|
863
|
+
@property
|
|
864
|
+
def vehicle_identifier_source(self) -> str:
|
|
865
|
+
"""Return which field supplies :pyattr:`vehicle_identifier`."""
|
|
866
|
+
|
|
867
|
+
if (self.vid or "").strip():
|
|
868
|
+
return "vid"
|
|
869
|
+
if (self.vin or "").strip():
|
|
870
|
+
return "vin"
|
|
871
|
+
return ""
|
|
872
|
+
|
|
873
|
+
@property
|
|
874
|
+
def kw(self) -> float:
|
|
875
|
+
"""Return consumed energy in kW for this session."""
|
|
876
|
+
start_val = None
|
|
877
|
+
if self.meter_start is not None:
|
|
878
|
+
start_val = float(self.meter_start) / 1000.0
|
|
879
|
+
|
|
880
|
+
end_val = None
|
|
881
|
+
if self.meter_stop is not None:
|
|
882
|
+
end_val = float(self.meter_stop) / 1000.0
|
|
883
|
+
|
|
884
|
+
readings = list(
|
|
885
|
+
self.meter_values.filter(energy__isnull=False).order_by("timestamp")
|
|
886
|
+
)
|
|
887
|
+
if readings:
|
|
888
|
+
if start_val is None:
|
|
889
|
+
start_val = float(readings[0].energy or 0)
|
|
890
|
+
# Always use the latest available reading for the end value when a
|
|
891
|
+
# stop meter has not been recorded yet. This allows active
|
|
892
|
+
# transactions to report totals using their most recent reading.
|
|
893
|
+
if end_val is None:
|
|
894
|
+
end_val = float(readings[-1].energy or 0)
|
|
895
|
+
|
|
896
|
+
if start_val is None or end_val is None:
|
|
897
|
+
return 0.0
|
|
898
|
+
|
|
899
|
+
total = end_val - start_val
|
|
900
|
+
return max(total, 0.0)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class MeterValue(Entity):
|
|
904
|
+
"""Parsed meter values reported by chargers."""
|
|
905
|
+
|
|
906
|
+
charger = models.ForeignKey(
|
|
907
|
+
Charger, on_delete=models.CASCADE, related_name="meter_values"
|
|
908
|
+
)
|
|
909
|
+
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
910
|
+
transaction = models.ForeignKey(
|
|
911
|
+
Transaction,
|
|
912
|
+
on_delete=models.CASCADE,
|
|
913
|
+
related_name="meter_values",
|
|
914
|
+
null=True,
|
|
915
|
+
blank=True,
|
|
916
|
+
)
|
|
917
|
+
timestamp = models.DateTimeField()
|
|
918
|
+
context = models.CharField(max_length=32, blank=True)
|
|
919
|
+
energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
|
|
920
|
+
voltage = models.DecimalField(
|
|
921
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
922
|
+
)
|
|
923
|
+
current_import = models.DecimalField(
|
|
924
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
925
|
+
)
|
|
926
|
+
current_offered = models.DecimalField(
|
|
927
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
928
|
+
)
|
|
929
|
+
temperature = models.DecimalField(
|
|
930
|
+
max_digits=12, decimal_places=3, null=True, blank=True
|
|
931
|
+
)
|
|
932
|
+
soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
|
|
933
|
+
|
|
934
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
935
|
+
return f"{self.charger} {self.timestamp}"
|
|
936
|
+
|
|
937
|
+
@property
|
|
938
|
+
def value(self):
|
|
939
|
+
return self.energy
|
|
940
|
+
|
|
941
|
+
@value.setter
|
|
942
|
+
def value(self, new_value):
|
|
943
|
+
self.energy = new_value
|
|
944
|
+
|
|
945
|
+
class Meta:
|
|
946
|
+
verbose_name = _("Meter Value")
|
|
947
|
+
verbose_name_plural = _("Meter Values")
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
class MeterReadingManager(EntityManager):
|
|
951
|
+
def _normalize_kwargs(self, kwargs: dict) -> dict:
|
|
952
|
+
normalized = dict(kwargs)
|
|
953
|
+
value = normalized.pop("value", None)
|
|
954
|
+
unit = normalized.pop("unit", None)
|
|
955
|
+
if value is not None:
|
|
956
|
+
energy = value
|
|
957
|
+
try:
|
|
958
|
+
energy = Decimal(value)
|
|
959
|
+
except (InvalidOperation, TypeError, ValueError):
|
|
960
|
+
energy = None
|
|
961
|
+
if energy is not None:
|
|
962
|
+
unit_normalized = (unit or "").lower()
|
|
963
|
+
if unit_normalized in {"w", "wh"}:
|
|
964
|
+
energy = energy / Decimal("1000")
|
|
965
|
+
normalized.setdefault("energy", energy)
|
|
966
|
+
normalized.pop("measurand", None)
|
|
967
|
+
return normalized
|
|
968
|
+
|
|
969
|
+
def create(self, **kwargs):
|
|
970
|
+
return super().create(**self._normalize_kwargs(kwargs))
|
|
971
|
+
|
|
972
|
+
def get_or_create(self, defaults=None, **kwargs):
|
|
973
|
+
if defaults:
|
|
974
|
+
defaults = self._normalize_kwargs(defaults)
|
|
975
|
+
return super().get_or_create(
|
|
976
|
+
defaults=defaults, **self._normalize_kwargs(kwargs)
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
class MeterReading(MeterValue):
|
|
981
|
+
"""Proxy model for backwards compatibility."""
|
|
982
|
+
|
|
983
|
+
objects = MeterReadingManager()
|
|
984
|
+
|
|
985
|
+
class Meta:
|
|
986
|
+
proxy = True
|
|
987
|
+
verbose_name = _("Meter Value")
|
|
988
|
+
verbose_name_plural = _("Meter Values")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
class Simulator(Entity):
|
|
992
|
+
"""Preconfigured simulator that can be started from the admin."""
|
|
993
|
+
|
|
994
|
+
name = models.CharField(max_length=100, unique=True)
|
|
995
|
+
cp_path = models.CharField(
|
|
996
|
+
_("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
|
|
997
|
+
)
|
|
998
|
+
host = models.CharField(max_length=100, default="127.0.0.1")
|
|
999
|
+
ws_port = models.IntegerField(
|
|
1000
|
+
_("WS Port"), default=8000, null=True, blank=True
|
|
1001
|
+
)
|
|
1002
|
+
rfid = models.CharField(
|
|
1003
|
+
max_length=255,
|
|
1004
|
+
default="FFFFFFFF",
|
|
1005
|
+
verbose_name=_("RFID"),
|
|
1006
|
+
)
|
|
1007
|
+
vin = models.CharField(max_length=17, blank=True)
|
|
1008
|
+
serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
|
|
1009
|
+
connector_id = models.IntegerField(_("Connector ID"), default=1)
|
|
1010
|
+
duration = models.IntegerField(default=600)
|
|
1011
|
+
interval = models.FloatField(default=5.0)
|
|
1012
|
+
pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
|
|
1013
|
+
kw_max = models.FloatField(default=60.0)
|
|
1014
|
+
repeat = models.BooleanField(default=False)
|
|
1015
|
+
username = models.CharField(max_length=100, blank=True)
|
|
1016
|
+
password = models.CharField(max_length=100, blank=True)
|
|
1017
|
+
door_open = models.BooleanField(
|
|
1018
|
+
_("Door Open"),
|
|
1019
|
+
default=False,
|
|
1020
|
+
help_text=_("Send a DoorOpen error StatusNotification when enabled."),
|
|
1021
|
+
)
|
|
1022
|
+
configuration_keys = models.JSONField(
|
|
1023
|
+
default=list,
|
|
1024
|
+
blank=True,
|
|
1025
|
+
help_text=_(
|
|
1026
|
+
"List of configurationKey entries to return for GetConfiguration calls."
|
|
1027
|
+
),
|
|
1028
|
+
)
|
|
1029
|
+
configuration_unknown_keys = models.JSONField(
|
|
1030
|
+
default=list,
|
|
1031
|
+
blank=True,
|
|
1032
|
+
help_text=_("Keys to include in the GetConfiguration unknownKey response."),
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1036
|
+
return self.name
|
|
1037
|
+
|
|
1038
|
+
class Meta:
|
|
1039
|
+
verbose_name = _("CP Simulator")
|
|
1040
|
+
verbose_name_plural = _("CP Simulators")
|
|
1041
|
+
|
|
1042
|
+
def as_config(self):
|
|
1043
|
+
from .simulator import SimulatorConfig
|
|
1044
|
+
|
|
1045
|
+
return SimulatorConfig(
|
|
1046
|
+
host=self.host,
|
|
1047
|
+
ws_port=self.ws_port,
|
|
1048
|
+
rfid=self.rfid,
|
|
1049
|
+
vin=self.vin,
|
|
1050
|
+
cp_path=self.cp_path,
|
|
1051
|
+
serial_number=self.serial_number,
|
|
1052
|
+
connector_id=self.connector_id,
|
|
1053
|
+
duration=self.duration,
|
|
1054
|
+
interval=self.interval,
|
|
1055
|
+
pre_charge_delay=self.pre_charge_delay,
|
|
1056
|
+
kw_max=self.kw_max,
|
|
1057
|
+
repeat=self.repeat,
|
|
1058
|
+
username=self.username or None,
|
|
1059
|
+
password=self.password or None,
|
|
1060
|
+
configuration_keys=self.configuration_keys or [],
|
|
1061
|
+
configuration_unknown_keys=self.configuration_unknown_keys or [],
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
@property
|
|
1065
|
+
def ws_url(self) -> str: # pragma: no cover - simple helper
|
|
1066
|
+
path = self.cp_path
|
|
1067
|
+
if not path.endswith("/"):
|
|
1068
|
+
path += "/"
|
|
1069
|
+
if self.ws_port:
|
|
1070
|
+
return f"ws://{self.host}:{self.ws_port}/{path}"
|
|
1071
|
+
return f"ws://{self.host}/{path}"
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
class DataTransferMessage(models.Model):
|
|
1075
|
+
"""Persisted record of OCPP DataTransfer exchanges."""
|
|
1076
|
+
|
|
1077
|
+
DIRECTION_CP_TO_CSMS = "cp_to_csms"
|
|
1078
|
+
DIRECTION_CSMS_TO_CP = "csms_to_cp"
|
|
1079
|
+
DIRECTION_CHOICES = (
|
|
1080
|
+
(DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
|
|
1081
|
+
(DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
charger = models.ForeignKey(
|
|
1085
|
+
"Charger",
|
|
1086
|
+
on_delete=models.CASCADE,
|
|
1087
|
+
related_name="data_transfer_messages",
|
|
1088
|
+
)
|
|
1089
|
+
connector_id = models.PositiveIntegerField(
|
|
1090
|
+
null=True,
|
|
1091
|
+
blank=True,
|
|
1092
|
+
verbose_name="Connector ID",
|
|
1093
|
+
)
|
|
1094
|
+
direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
|
|
1095
|
+
ocpp_message_id = models.CharField(
|
|
1096
|
+
max_length=64,
|
|
1097
|
+
verbose_name="OCPP message ID",
|
|
1098
|
+
)
|
|
1099
|
+
vendor_id = models.CharField(
|
|
1100
|
+
max_length=255,
|
|
1101
|
+
blank=True,
|
|
1102
|
+
verbose_name="Vendor ID",
|
|
1103
|
+
)
|
|
1104
|
+
message_id = models.CharField(
|
|
1105
|
+
max_length=255,
|
|
1106
|
+
blank=True,
|
|
1107
|
+
verbose_name="Message ID",
|
|
1108
|
+
)
|
|
1109
|
+
payload = models.JSONField(default=dict, blank=True)
|
|
1110
|
+
status = models.CharField(max_length=64, blank=True)
|
|
1111
|
+
response_data = models.JSONField(null=True, blank=True)
|
|
1112
|
+
error_code = models.CharField(max_length=64, blank=True)
|
|
1113
|
+
error_description = models.TextField(blank=True)
|
|
1114
|
+
error_details = models.JSONField(null=True, blank=True)
|
|
1115
|
+
responded_at = models.DateTimeField(null=True, blank=True)
|
|
1116
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
1117
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
1118
|
+
|
|
1119
|
+
class Meta:
|
|
1120
|
+
ordering = ["-created_at"]
|
|
1121
|
+
verbose_name = _("Data Message")
|
|
1122
|
+
verbose_name_plural = _("Data Messages")
|
|
1123
|
+
indexes = [
|
|
1124
|
+
models.Index(
|
|
1125
|
+
fields=["ocpp_message_id"],
|
|
1126
|
+
name="ocpp_datatr_ocpp_me_70d17f_idx",
|
|
1127
|
+
),
|
|
1128
|
+
models.Index(
|
|
1129
|
+
fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
|
|
1130
|
+
),
|
|
1131
|
+
]
|
|
1132
|
+
|
|
1133
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1134
|
+
return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
class CPReservation(Entity):
|
|
1138
|
+
"""Track connector reservations dispatched to an EVCS."""
|
|
1139
|
+
|
|
1140
|
+
location = models.ForeignKey(
|
|
1141
|
+
Location,
|
|
1142
|
+
on_delete=models.PROTECT,
|
|
1143
|
+
related_name="reservations",
|
|
1144
|
+
verbose_name=_("Location"),
|
|
1145
|
+
)
|
|
1146
|
+
connector = models.ForeignKey(
|
|
1147
|
+
Charger,
|
|
1148
|
+
on_delete=models.PROTECT,
|
|
1149
|
+
related_name="reservations",
|
|
1150
|
+
verbose_name=_("Connector"),
|
|
1151
|
+
)
|
|
1152
|
+
account = models.ForeignKey(
|
|
1153
|
+
EnergyAccount,
|
|
1154
|
+
on_delete=models.SET_NULL,
|
|
1155
|
+
null=True,
|
|
1156
|
+
blank=True,
|
|
1157
|
+
related_name="cp_reservations",
|
|
1158
|
+
verbose_name=_("Energy account"),
|
|
1159
|
+
)
|
|
1160
|
+
rfid = models.ForeignKey(
|
|
1161
|
+
CoreRFID,
|
|
1162
|
+
on_delete=models.SET_NULL,
|
|
1163
|
+
null=True,
|
|
1164
|
+
blank=True,
|
|
1165
|
+
related_name="cp_reservations",
|
|
1166
|
+
verbose_name=_("RFID"),
|
|
1167
|
+
)
|
|
1168
|
+
id_tag = models.CharField(
|
|
1169
|
+
_("Id Tag"),
|
|
1170
|
+
max_length=255,
|
|
1171
|
+
blank=True,
|
|
1172
|
+
default="",
|
|
1173
|
+
help_text=_("Identifier sent to the EVCS when reserving the connector."),
|
|
1174
|
+
)
|
|
1175
|
+
start_time = models.DateTimeField(verbose_name=_("Start time"))
|
|
1176
|
+
duration_minutes = models.PositiveIntegerField(
|
|
1177
|
+
verbose_name=_("Duration (minutes)"),
|
|
1178
|
+
default=120,
|
|
1179
|
+
help_text=_("Reservation window length in minutes."),
|
|
1180
|
+
)
|
|
1181
|
+
evcs_status = models.CharField(
|
|
1182
|
+
max_length=32,
|
|
1183
|
+
blank=True,
|
|
1184
|
+
default="",
|
|
1185
|
+
verbose_name=_("EVCS status"),
|
|
1186
|
+
)
|
|
1187
|
+
evcs_error = models.CharField(
|
|
1188
|
+
max_length=255,
|
|
1189
|
+
blank=True,
|
|
1190
|
+
default="",
|
|
1191
|
+
verbose_name=_("EVCS error"),
|
|
1192
|
+
)
|
|
1193
|
+
evcs_confirmed = models.BooleanField(
|
|
1194
|
+
default=False,
|
|
1195
|
+
verbose_name=_("Reservation confirmed"),
|
|
1196
|
+
)
|
|
1197
|
+
evcs_confirmed_at = models.DateTimeField(
|
|
1198
|
+
null=True,
|
|
1199
|
+
blank=True,
|
|
1200
|
+
verbose_name=_("Confirmed at"),
|
|
1201
|
+
)
|
|
1202
|
+
ocpp_message_id = models.CharField(
|
|
1203
|
+
max_length=36,
|
|
1204
|
+
blank=True,
|
|
1205
|
+
default="",
|
|
1206
|
+
editable=False,
|
|
1207
|
+
verbose_name=_("OCPP message id"),
|
|
1208
|
+
)
|
|
1209
|
+
created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Created on"))
|
|
1210
|
+
updated_on = models.DateTimeField(auto_now=True, verbose_name=_("Updated on"))
|
|
1211
|
+
|
|
1212
|
+
class Meta:
|
|
1213
|
+
ordering = ["-start_time"]
|
|
1214
|
+
verbose_name = _("CP Reservation")
|
|
1215
|
+
verbose_name_plural = _("CP Reservations")
|
|
1216
|
+
|
|
1217
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
1218
|
+
start = timezone.localtime(self.start_time) if self.start_time else ""
|
|
1219
|
+
return f"{self.location} @ {start}" if self.location else str(start)
|
|
1220
|
+
|
|
1221
|
+
@property
|
|
1222
|
+
def end_time(self):
|
|
1223
|
+
duration = max(int(self.duration_minutes or 0), 0)
|
|
1224
|
+
return self.start_time + timedelta(minutes=duration)
|
|
1225
|
+
|
|
1226
|
+
@property
|
|
1227
|
+
def connector_label(self) -> str:
|
|
1228
|
+
if self.connector_id:
|
|
1229
|
+
return self.connector.connector_label
|
|
1230
|
+
return ""
|
|
1231
|
+
|
|
1232
|
+
@property
|
|
1233
|
+
def id_tag_value(self) -> str:
|
|
1234
|
+
if self.id_tag:
|
|
1235
|
+
return self.id_tag.strip()
|
|
1236
|
+
if self.rfid_id:
|
|
1237
|
+
return (self.rfid.rfid or "").strip()
|
|
1238
|
+
return ""
|
|
1239
|
+
|
|
1240
|
+
def allocate_connector(self, *, force: bool = False) -> Charger:
|
|
1241
|
+
"""Select an available connector for this reservation."""
|
|
1242
|
+
|
|
1243
|
+
if not self.location_id:
|
|
1244
|
+
raise ValidationError({"location": _("Select a location for the reservation.")})
|
|
1245
|
+
if not self.start_time:
|
|
1246
|
+
raise ValidationError({"start_time": _("Provide a start time for the reservation.")})
|
|
1247
|
+
if self.duration_minutes <= 0:
|
|
1248
|
+
raise ValidationError(
|
|
1249
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
candidates = list(
|
|
1253
|
+
Charger.objects.filter(
|
|
1254
|
+
location=self.location, connector_id__isnull=False
|
|
1255
|
+
).order_by("connector_id")
|
|
1256
|
+
)
|
|
1257
|
+
if not candidates:
|
|
1258
|
+
raise ValidationError(
|
|
1259
|
+
{"location": _("No connectors are configured for the selected location.")}
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
def _priority(charger: Charger) -> tuple[int, int]:
|
|
1263
|
+
connector_id = charger.connector_id or 0
|
|
1264
|
+
if connector_id == 2:
|
|
1265
|
+
return (0, connector_id)
|
|
1266
|
+
if connector_id == 1:
|
|
1267
|
+
return (1, connector_id)
|
|
1268
|
+
return (2, connector_id)
|
|
1269
|
+
|
|
1270
|
+
def _is_available(charger: Charger) -> bool:
|
|
1271
|
+
existing = type(self).objects.filter(connector=charger).exclude(pk=self.pk)
|
|
1272
|
+
start = self.start_time
|
|
1273
|
+
end = self.end_time
|
|
1274
|
+
for entry in existing:
|
|
1275
|
+
if entry.start_time < end and entry.end_time > start:
|
|
1276
|
+
return False
|
|
1277
|
+
return True
|
|
1278
|
+
|
|
1279
|
+
if self.connector_id:
|
|
1280
|
+
current = next((c for c in candidates if c.pk == self.connector_id), None)
|
|
1281
|
+
if current and _is_available(current) and not force:
|
|
1282
|
+
return current
|
|
1283
|
+
|
|
1284
|
+
for charger in sorted(candidates, key=_priority):
|
|
1285
|
+
if _is_available(charger):
|
|
1286
|
+
self.connector = charger
|
|
1287
|
+
return charger
|
|
1288
|
+
|
|
1289
|
+
raise ValidationError(
|
|
1290
|
+
_("All connectors at this location are reserved for the selected time window.")
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
def clean(self):
|
|
1294
|
+
super().clean()
|
|
1295
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1296
|
+
self.start_time = timezone.make_aware(
|
|
1297
|
+
self.start_time, timezone.get_current_timezone()
|
|
1298
|
+
)
|
|
1299
|
+
if self.duration_minutes <= 0:
|
|
1300
|
+
raise ValidationError(
|
|
1301
|
+
{"duration_minutes": _("Reservation window must be at least one minute.")}
|
|
1302
|
+
)
|
|
1303
|
+
try:
|
|
1304
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1305
|
+
except ValidationError as exc:
|
|
1306
|
+
raise ValidationError(exc) from exc
|
|
1307
|
+
|
|
1308
|
+
def save(self, *args, **kwargs):
|
|
1309
|
+
if self.start_time and timezone.is_naive(self.start_time):
|
|
1310
|
+
self.start_time = timezone.make_aware(
|
|
1311
|
+
self.start_time, timezone.get_current_timezone()
|
|
1312
|
+
)
|
|
1313
|
+
update_fields = kwargs.get("update_fields")
|
|
1314
|
+
relevant_fields = {"location", "start_time", "duration_minutes", "connector"}
|
|
1315
|
+
should_allocate = True
|
|
1316
|
+
if update_fields is not None and not relevant_fields.intersection(update_fields):
|
|
1317
|
+
should_allocate = False
|
|
1318
|
+
if should_allocate:
|
|
1319
|
+
self.allocate_connector(force=bool(self.pk))
|
|
1320
|
+
super().save(*args, **kwargs)
|
|
1321
|
+
|
|
1322
|
+
def send_reservation_request(self) -> str:
|
|
1323
|
+
"""Dispatch a ReserveNow request to the associated connector."""
|
|
1324
|
+
|
|
1325
|
+
if not self.pk:
|
|
1326
|
+
raise ValidationError(_("Save the reservation before sending it to the EVCS."))
|
|
1327
|
+
connector = self.connector
|
|
1328
|
+
if connector is None or connector.connector_id is None:
|
|
1329
|
+
raise ValidationError(_("Unable to determine which connector to reserve."))
|
|
1330
|
+
id_tag = self.id_tag_value
|
|
1331
|
+
if not id_tag:
|
|
1332
|
+
raise ValidationError(
|
|
1333
|
+
_("Provide an RFID or idTag before creating the reservation.")
|
|
1334
|
+
)
|
|
1335
|
+
connection = store.get_connection(connector.charger_id, connector.connector_id)
|
|
1336
|
+
if connection is None:
|
|
1337
|
+
raise ValidationError(
|
|
1338
|
+
_("The selected charge point is not currently connected to the system.")
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
message_id = uuid.uuid4().hex
|
|
1342
|
+
expiry = timezone.localtime(self.end_time)
|
|
1343
|
+
payload = {
|
|
1344
|
+
"connectorId": connector.connector_id,
|
|
1345
|
+
"expiryDate": expiry.isoformat(),
|
|
1346
|
+
"idTag": id_tag,
|
|
1347
|
+
"reservationId": self.pk,
|
|
1348
|
+
}
|
|
1349
|
+
frame = json.dumps([2, message_id, "ReserveNow", payload])
|
|
1350
|
+
|
|
1351
|
+
log_key = store.identity_key(connector.charger_id, connector.connector_id)
|
|
1352
|
+
store.add_log(
|
|
1353
|
+
log_key,
|
|
1354
|
+
f"ReserveNow request: reservation={self.pk}, expiry={expiry.isoformat()}",
|
|
1355
|
+
log_type="charger",
|
|
1356
|
+
)
|
|
1357
|
+
async_to_sync(connection.send)(frame)
|
|
1358
|
+
|
|
1359
|
+
metadata = {
|
|
1360
|
+
"action": "ReserveNow",
|
|
1361
|
+
"charger_id": connector.charger_id,
|
|
1362
|
+
"connector_id": connector.connector_id,
|
|
1363
|
+
"log_key": log_key,
|
|
1364
|
+
"reservation_pk": self.pk,
|
|
1365
|
+
"requested_at": timezone.now(),
|
|
1366
|
+
}
|
|
1367
|
+
store.register_pending_call(message_id, metadata)
|
|
1368
|
+
store.schedule_call_timeout(message_id, action="ReserveNow", log_key=log_key)
|
|
1369
|
+
|
|
1370
|
+
self.ocpp_message_id = message_id
|
|
1371
|
+
self.evcs_status = ""
|
|
1372
|
+
self.evcs_error = ""
|
|
1373
|
+
self.evcs_confirmed = False
|
|
1374
|
+
self.evcs_confirmed_at = None
|
|
1375
|
+
super().save(
|
|
1376
|
+
update_fields=[
|
|
1377
|
+
"ocpp_message_id",
|
|
1378
|
+
"evcs_status",
|
|
1379
|
+
"evcs_error",
|
|
1380
|
+
"evcs_confirmed",
|
|
1381
|
+
"evcs_confirmed_at",
|
|
1382
|
+
"updated_on",
|
|
1383
|
+
]
|
|
1384
|
+
)
|
|
1385
|
+
return message_id
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
class RFID(CoreRFID):
|
|
1389
|
+
class Meta:
|
|
1390
|
+
proxy = True
|
|
1391
|
+
app_label = "ocpp"
|
|
1392
|
+
verbose_name = CoreRFID._meta.verbose_name
|
|
1393
|
+
verbose_name_plural = CoreRFID._meta.verbose_name_plural
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
class ElectricVehicle(CoreElectricVehicle):
|
|
1397
|
+
class Meta:
|
|
1398
|
+
proxy = True
|
|
1399
|
+
app_label = "ocpp"
|
|
1400
|
+
verbose_name = _("Electric Vehicle")
|
|
1401
|
+
verbose_name_plural = _("Electric Vehicles")
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
class Brand(CoreBrand):
|
|
1405
|
+
class Meta:
|
|
1406
|
+
proxy = True
|
|
1407
|
+
app_label = "ocpp"
|
|
1408
|
+
verbose_name = CoreBrand._meta.verbose_name
|
|
1409
|
+
verbose_name_plural = CoreBrand._meta.verbose_name_plural
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
class EVModel(CoreEVModel):
|
|
1413
|
+
class Meta:
|
|
1414
|
+
proxy = True
|
|
1415
|
+
app_label = "ocpp"
|
|
1416
|
+
verbose_name = CoreEVModel._meta.verbose_name
|
|
1417
|
+
verbose_name_plural = CoreEVModel._meta.verbose_name_plural
|