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/consumers.py
CHANGED
|
@@ -1,630 +1,1843 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
from . import
|
|
14
|
-
|
|
15
|
-
from
|
|
16
|
-
from .
|
|
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
|
-
self.
|
|
170
|
-
|
|
171
|
-
async def
|
|
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
|
-
self.
|
|
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
|
-
return
|
|
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
|
-
|
|
533
|
-
await
|
|
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
|
-
tx_obj
|
|
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
|
-
|
|
1
|
+
import base64
|
|
2
|
+
import ipaddress
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from urllib.parse import parse_qs
|
|
10
|
+
from django.utils import timezone
|
|
11
|
+
from core.models import EnergyAccount, Reference, RFID as CoreRFID
|
|
12
|
+
from nodes.models import NetMessage
|
|
13
|
+
from django.core.exceptions import ValidationError
|
|
14
|
+
|
|
15
|
+
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
16
|
+
from channels.db import database_sync_to_async
|
|
17
|
+
from asgiref.sync import sync_to_async
|
|
18
|
+
from config.offline import requires_network
|
|
19
|
+
|
|
20
|
+
from . import store
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from django.utils.dateparse import parse_datetime
|
|
23
|
+
from .models import (
|
|
24
|
+
Transaction,
|
|
25
|
+
Charger,
|
|
26
|
+
ChargerConfiguration,
|
|
27
|
+
MeterValue,
|
|
28
|
+
DataTransferMessage,
|
|
29
|
+
CPReservation,
|
|
30
|
+
)
|
|
31
|
+
from .reference_utils import host_is_local_loopback
|
|
32
|
+
from .evcs_discovery import (
|
|
33
|
+
DEFAULT_CONSOLE_PORT,
|
|
34
|
+
HTTPS_PORTS,
|
|
35
|
+
build_console_url,
|
|
36
|
+
prioritise_ports,
|
|
37
|
+
scan_open_ports,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Query parameter keys that may contain the charge point serial. Keys are
|
|
47
|
+
# matched case-insensitively and trimmed before use.
|
|
48
|
+
SERIAL_QUERY_PARAM_NAMES = (
|
|
49
|
+
"cid",
|
|
50
|
+
"chargepointid",
|
|
51
|
+
"charge_point_id",
|
|
52
|
+
"chargeboxid",
|
|
53
|
+
"charge_box_id",
|
|
54
|
+
"chargerid",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_ip(value: str | None):
|
|
59
|
+
"""Return an :mod:`ipaddress` object for the provided value, if valid."""
|
|
60
|
+
|
|
61
|
+
candidate = (value or "").strip()
|
|
62
|
+
if not candidate or candidate.lower() == "unknown":
|
|
63
|
+
return None
|
|
64
|
+
if candidate.lower().startswith("for="):
|
|
65
|
+
candidate = candidate[4:].strip()
|
|
66
|
+
candidate = candidate.strip("'\"")
|
|
67
|
+
if candidate.startswith("["):
|
|
68
|
+
closing = candidate.find("]")
|
|
69
|
+
if closing != -1:
|
|
70
|
+
candidate = candidate[1:closing]
|
|
71
|
+
else:
|
|
72
|
+
candidate = candidate[1:]
|
|
73
|
+
# Remove any comma separated values that may remain.
|
|
74
|
+
if "," in candidate:
|
|
75
|
+
candidate = candidate.split(",", 1)[0].strip()
|
|
76
|
+
try:
|
|
77
|
+
parsed = ipaddress.ip_address(candidate)
|
|
78
|
+
except ValueError:
|
|
79
|
+
host, sep, maybe_port = candidate.rpartition(":")
|
|
80
|
+
if not sep or not maybe_port.isdigit():
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
parsed = ipaddress.ip_address(host)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
return parsed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_client_ip(scope: dict) -> str | None:
|
|
90
|
+
"""Return the most useful client IP for the provided ASGI scope."""
|
|
91
|
+
|
|
92
|
+
headers = scope.get("headers") or []
|
|
93
|
+
header_map: dict[str, list[str]] = {}
|
|
94
|
+
for key_bytes, value_bytes in headers:
|
|
95
|
+
try:
|
|
96
|
+
key = key_bytes.decode("latin1").lower()
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
value = value_bytes.decode("latin1")
|
|
101
|
+
except Exception:
|
|
102
|
+
value = ""
|
|
103
|
+
header_map.setdefault(key, []).append(value)
|
|
104
|
+
|
|
105
|
+
candidates: list[str] = []
|
|
106
|
+
for raw in header_map.get("x-forwarded-for", []):
|
|
107
|
+
candidates.extend(part.strip() for part in raw.split(","))
|
|
108
|
+
for raw in header_map.get("forwarded", []):
|
|
109
|
+
for segment in raw.split(","):
|
|
110
|
+
match = FORWARDED_PAIR_RE.search(segment)
|
|
111
|
+
if match:
|
|
112
|
+
candidates.append(match.group("value"))
|
|
113
|
+
candidates.extend(header_map.get("x-real-ip", []))
|
|
114
|
+
client = scope.get("client")
|
|
115
|
+
if client:
|
|
116
|
+
candidates.append((client[0] or "").strip())
|
|
117
|
+
|
|
118
|
+
fallback: str | None = None
|
|
119
|
+
for raw in candidates:
|
|
120
|
+
parsed = _parse_ip(raw)
|
|
121
|
+
if not parsed:
|
|
122
|
+
continue
|
|
123
|
+
ip_text = str(parsed)
|
|
124
|
+
if parsed.is_loopback:
|
|
125
|
+
if fallback is None:
|
|
126
|
+
fallback = ip_text
|
|
127
|
+
continue
|
|
128
|
+
return ip_text
|
|
129
|
+
return fallback
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_ocpp_timestamp(value) -> datetime | None:
|
|
133
|
+
"""Return an aware :class:`~datetime.datetime` for OCPP timestamps."""
|
|
134
|
+
|
|
135
|
+
if not value:
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(value, datetime):
|
|
138
|
+
timestamp = value
|
|
139
|
+
else:
|
|
140
|
+
timestamp = parse_datetime(str(value))
|
|
141
|
+
if not timestamp:
|
|
142
|
+
return None
|
|
143
|
+
if timezone.is_naive(timestamp):
|
|
144
|
+
timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
|
|
145
|
+
return timestamp
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
|
|
149
|
+
"""Return normalized VID and VIN values from an OCPP message payload."""
|
|
150
|
+
|
|
151
|
+
raw_vid = payload.get("vid")
|
|
152
|
+
vid_value = str(raw_vid).strip() if raw_vid is not None else ""
|
|
153
|
+
raw_vin = payload.get("vin")
|
|
154
|
+
vin_value = str(raw_vin).strip() if raw_vin is not None else ""
|
|
155
|
+
if not vid_value and vin_value:
|
|
156
|
+
vid_value = vin_value
|
|
157
|
+
return vid_value, vin_value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SinkConsumer(AsyncWebsocketConsumer):
|
|
161
|
+
"""Accept any message without validation."""
|
|
162
|
+
|
|
163
|
+
@requires_network
|
|
164
|
+
async def connect(self) -> None:
|
|
165
|
+
self.client_ip = _resolve_client_ip(self.scope)
|
|
166
|
+
if not store.register_ip_connection(self.client_ip, self):
|
|
167
|
+
await self.close(code=4003)
|
|
168
|
+
return
|
|
169
|
+
await self.accept()
|
|
170
|
+
|
|
171
|
+
async def disconnect(self, close_code):
|
|
172
|
+
store.release_ip_connection(getattr(self, "client_ip", None), self)
|
|
173
|
+
|
|
174
|
+
async def receive(
|
|
175
|
+
self, text_data: str | None = None, bytes_data: bytes | None = None
|
|
176
|
+
) -> None:
|
|
177
|
+
if text_data is None:
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
msg = json.loads(text_data)
|
|
181
|
+
if isinstance(msg, list) and msg and msg[0] == 2:
|
|
182
|
+
await self.send(json.dumps([3, msg[1], {}]))
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CSMSConsumer(AsyncWebsocketConsumer):
|
|
188
|
+
"""Very small subset of OCPP 1.6 CSMS behaviour."""
|
|
189
|
+
|
|
190
|
+
consumption_update_interval = 300
|
|
191
|
+
|
|
192
|
+
def _extract_serial_identifier(self) -> str:
|
|
193
|
+
"""Return the charge point serial from the query string or path."""
|
|
194
|
+
|
|
195
|
+
self.serial_source = None
|
|
196
|
+
query_bytes = self.scope.get("query_string") or b""
|
|
197
|
+
self._raw_query_string = query_bytes.decode("utf-8", "ignore") if query_bytes else ""
|
|
198
|
+
if query_bytes:
|
|
199
|
+
try:
|
|
200
|
+
parsed = parse_qs(
|
|
201
|
+
self._raw_query_string,
|
|
202
|
+
keep_blank_values=False,
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
parsed = {}
|
|
206
|
+
if parsed:
|
|
207
|
+
normalized = {
|
|
208
|
+
key.lower(): values for key, values in parsed.items() if values
|
|
209
|
+
}
|
|
210
|
+
for candidate in SERIAL_QUERY_PARAM_NAMES:
|
|
211
|
+
values = normalized.get(candidate)
|
|
212
|
+
if not values:
|
|
213
|
+
continue
|
|
214
|
+
for value in values:
|
|
215
|
+
if not value:
|
|
216
|
+
continue
|
|
217
|
+
trimmed = value.strip()
|
|
218
|
+
if trimmed:
|
|
219
|
+
return trimmed
|
|
220
|
+
|
|
221
|
+
return self.scope["url_route"]["kwargs"].get("cid", "")
|
|
222
|
+
|
|
223
|
+
@requires_network
|
|
224
|
+
async def connect(self):
|
|
225
|
+
raw_serial = self._extract_serial_identifier()
|
|
226
|
+
try:
|
|
227
|
+
self.charger_id = Charger.validate_serial(raw_serial)
|
|
228
|
+
except ValidationError as exc:
|
|
229
|
+
serial = Charger.normalize_serial(raw_serial)
|
|
230
|
+
store_key = store.pending_key(serial)
|
|
231
|
+
message = exc.messages[0] if exc.messages else "Invalid Serial Number"
|
|
232
|
+
details: list[str] = []
|
|
233
|
+
if getattr(self, "serial_source", None):
|
|
234
|
+
details.append(f"serial_source={self.serial_source}")
|
|
235
|
+
if getattr(self, "_raw_query_string", ""):
|
|
236
|
+
details.append(f"query_string={self._raw_query_string!r}")
|
|
237
|
+
if details:
|
|
238
|
+
message = f"{message} ({'; '.join(details)})"
|
|
239
|
+
store.add_log(
|
|
240
|
+
store_key,
|
|
241
|
+
f"Rejected connection: {message}",
|
|
242
|
+
log_type="charger",
|
|
243
|
+
)
|
|
244
|
+
await self.close(code=4003)
|
|
245
|
+
return
|
|
246
|
+
self.connector_value: int | None = None
|
|
247
|
+
self.store_key = store.pending_key(self.charger_id)
|
|
248
|
+
self.aggregate_charger: Charger | None = None
|
|
249
|
+
self._consumption_task: asyncio.Task | None = None
|
|
250
|
+
self._consumption_message_uuid: str | None = None
|
|
251
|
+
subprotocol = None
|
|
252
|
+
offered = self.scope.get("subprotocols", [])
|
|
253
|
+
if "ocpp1.6" in offered:
|
|
254
|
+
subprotocol = "ocpp1.6"
|
|
255
|
+
self.client_ip = _resolve_client_ip(self.scope)
|
|
256
|
+
self._header_reference_created = False
|
|
257
|
+
# Close any pending connection for this charger so reconnections do
|
|
258
|
+
# not leak stale consumers when the connector id has not been
|
|
259
|
+
# negotiated yet.
|
|
260
|
+
existing = store.connections.get(self.store_key)
|
|
261
|
+
if existing is not None:
|
|
262
|
+
store.release_ip_connection(getattr(existing, "client_ip", None), existing)
|
|
263
|
+
await existing.close()
|
|
264
|
+
if not store.register_ip_connection(self.client_ip, self):
|
|
265
|
+
store.add_log(
|
|
266
|
+
self.store_key,
|
|
267
|
+
f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
|
|
268
|
+
log_type="charger",
|
|
269
|
+
)
|
|
270
|
+
await self.close(code=4003)
|
|
271
|
+
return
|
|
272
|
+
await self.accept(subprotocol=subprotocol)
|
|
273
|
+
store.add_log(
|
|
274
|
+
self.store_key,
|
|
275
|
+
f"Connected (subprotocol={subprotocol or 'none'})",
|
|
276
|
+
log_type="charger",
|
|
277
|
+
)
|
|
278
|
+
store.connections[self.store_key] = self
|
|
279
|
+
store.logs["charger"].setdefault(self.store_key, [])
|
|
280
|
+
self.charger, created = await database_sync_to_async(
|
|
281
|
+
Charger.objects.get_or_create
|
|
282
|
+
)(
|
|
283
|
+
charger_id=self.charger_id,
|
|
284
|
+
connector_id=None,
|
|
285
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
286
|
+
)
|
|
287
|
+
await database_sync_to_async(self.charger.refresh_manager_node)()
|
|
288
|
+
self.aggregate_charger = self.charger
|
|
289
|
+
location_name = await sync_to_async(
|
|
290
|
+
lambda: self.charger.location.name if self.charger.location else ""
|
|
291
|
+
)()
|
|
292
|
+
friendly_name = location_name or self.charger_id
|
|
293
|
+
store.register_log_name(self.store_key, friendly_name, log_type="charger")
|
|
294
|
+
store.register_log_name(self.charger_id, friendly_name, log_type="charger")
|
|
295
|
+
store.register_log_name(
|
|
296
|
+
store.identity_key(self.charger_id, None),
|
|
297
|
+
friendly_name,
|
|
298
|
+
log_type="charger",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def _get_account(self, id_tag: str) -> EnergyAccount | None:
|
|
302
|
+
"""Return the energy account for the provided RFID if valid."""
|
|
303
|
+
if not id_tag:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def _resolve() -> EnergyAccount | None:
|
|
307
|
+
matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
|
|
308
|
+
if not matches.exists():
|
|
309
|
+
return None
|
|
310
|
+
return (
|
|
311
|
+
EnergyAccount.objects.filter(rfids__in=matches)
|
|
312
|
+
.distinct()
|
|
313
|
+
.first()
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return await database_sync_to_async(_resolve)()
|
|
317
|
+
|
|
318
|
+
async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
|
|
319
|
+
"""Ensure an RFID record exists and update its last seen timestamp."""
|
|
320
|
+
|
|
321
|
+
if not id_tag:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
normalized = id_tag.upper()
|
|
325
|
+
|
|
326
|
+
def _ensure() -> CoreRFID:
|
|
327
|
+
now = timezone.now()
|
|
328
|
+
tag, _created = CoreRFID.register_scan(normalized)
|
|
329
|
+
updates = []
|
|
330
|
+
if not tag.allowed:
|
|
331
|
+
tag.allowed = True
|
|
332
|
+
updates.append("allowed")
|
|
333
|
+
if tag.last_seen_on != now:
|
|
334
|
+
tag.last_seen_on = now
|
|
335
|
+
updates.append("last_seen_on")
|
|
336
|
+
if updates:
|
|
337
|
+
tag.save(update_fields=updates)
|
|
338
|
+
return tag
|
|
339
|
+
|
|
340
|
+
return await database_sync_to_async(_ensure)()
|
|
341
|
+
|
|
342
|
+
def _log_unlinked_rfid(self, rfid: str) -> None:
|
|
343
|
+
"""Record a warning when an RFID is authorized without an account."""
|
|
344
|
+
|
|
345
|
+
message = (
|
|
346
|
+
f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
|
|
347
|
+
)
|
|
348
|
+
logger.warning(message)
|
|
349
|
+
store.add_log(
|
|
350
|
+
store.pending_key(self.charger_id),
|
|
351
|
+
message,
|
|
352
|
+
log_type="charger",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def _assign_connector(self, connector: int | str | None) -> None:
|
|
356
|
+
"""Ensure ``self.charger`` matches the provided connector id."""
|
|
357
|
+
if connector in (None, "", "-"):
|
|
358
|
+
connector_value = None
|
|
359
|
+
else:
|
|
360
|
+
try:
|
|
361
|
+
connector_value = int(connector)
|
|
362
|
+
if connector_value == 0:
|
|
363
|
+
connector_value = None
|
|
364
|
+
except (TypeError, ValueError):
|
|
365
|
+
return
|
|
366
|
+
if connector_value is None:
|
|
367
|
+
aggregate = self.aggregate_charger
|
|
368
|
+
if (
|
|
369
|
+
not aggregate
|
|
370
|
+
or aggregate.connector_id is not None
|
|
371
|
+
or aggregate.charger_id != self.charger_id
|
|
372
|
+
):
|
|
373
|
+
aggregate, _ = await database_sync_to_async(
|
|
374
|
+
Charger.objects.get_or_create
|
|
375
|
+
)(
|
|
376
|
+
charger_id=self.charger_id,
|
|
377
|
+
connector_id=None,
|
|
378
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
379
|
+
)
|
|
380
|
+
await database_sync_to_async(aggregate.refresh_manager_node)()
|
|
381
|
+
self.aggregate_charger = aggregate
|
|
382
|
+
self.charger = self.aggregate_charger
|
|
383
|
+
previous_key = self.store_key
|
|
384
|
+
new_key = store.identity_key(self.charger_id, None)
|
|
385
|
+
if previous_key != new_key:
|
|
386
|
+
existing_consumer = store.connections.get(new_key)
|
|
387
|
+
if existing_consumer is not None and existing_consumer is not self:
|
|
388
|
+
await existing_consumer.close()
|
|
389
|
+
store.reassign_identity(previous_key, new_key)
|
|
390
|
+
store.connections[new_key] = self
|
|
391
|
+
store.logs["charger"].setdefault(new_key, [])
|
|
392
|
+
aggregate_name = await sync_to_async(
|
|
393
|
+
lambda: self.charger.name or self.charger.charger_id
|
|
394
|
+
)()
|
|
395
|
+
friendly_name = aggregate_name or self.charger_id
|
|
396
|
+
store.register_log_name(new_key, friendly_name, log_type="charger")
|
|
397
|
+
store.register_log_name(
|
|
398
|
+
store.identity_key(self.charger_id, None),
|
|
399
|
+
friendly_name,
|
|
400
|
+
log_type="charger",
|
|
401
|
+
)
|
|
402
|
+
store.register_log_name(self.charger_id, friendly_name, log_type="charger")
|
|
403
|
+
self.store_key = new_key
|
|
404
|
+
self.connector_value = None
|
|
405
|
+
if not self._header_reference_created and self.client_ip:
|
|
406
|
+
await database_sync_to_async(self._ensure_console_reference)()
|
|
407
|
+
self._header_reference_created = True
|
|
408
|
+
return
|
|
409
|
+
if (
|
|
410
|
+
self.connector_value == connector_value
|
|
411
|
+
and self.charger.connector_id == connector_value
|
|
412
|
+
):
|
|
413
|
+
return
|
|
414
|
+
if (
|
|
415
|
+
not self.aggregate_charger
|
|
416
|
+
or self.aggregate_charger.connector_id is not None
|
|
417
|
+
):
|
|
418
|
+
aggregate, _ = await database_sync_to_async(
|
|
419
|
+
Charger.objects.get_or_create
|
|
420
|
+
)(
|
|
421
|
+
charger_id=self.charger_id,
|
|
422
|
+
connector_id=None,
|
|
423
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
424
|
+
)
|
|
425
|
+
await database_sync_to_async(aggregate.refresh_manager_node)()
|
|
426
|
+
self.aggregate_charger = aggregate
|
|
427
|
+
existing = await database_sync_to_async(
|
|
428
|
+
Charger.objects.filter(
|
|
429
|
+
charger_id=self.charger_id, connector_id=connector_value
|
|
430
|
+
).first
|
|
431
|
+
)()
|
|
432
|
+
if existing:
|
|
433
|
+
self.charger = existing
|
|
434
|
+
await database_sync_to_async(self.charger.refresh_manager_node)()
|
|
435
|
+
else:
|
|
436
|
+
|
|
437
|
+
def _create_connector():
|
|
438
|
+
charger, _ = Charger.objects.get_or_create(
|
|
439
|
+
charger_id=self.charger_id,
|
|
440
|
+
connector_id=connector_value,
|
|
441
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
442
|
+
)
|
|
443
|
+
if self.scope.get("path") and charger.last_path != self.scope.get(
|
|
444
|
+
"path"
|
|
445
|
+
):
|
|
446
|
+
charger.last_path = self.scope.get("path")
|
|
447
|
+
charger.save(update_fields=["last_path"])
|
|
448
|
+
charger.refresh_manager_node()
|
|
449
|
+
return charger
|
|
450
|
+
|
|
451
|
+
self.charger = await database_sync_to_async(_create_connector)()
|
|
452
|
+
previous_key = self.store_key
|
|
453
|
+
new_key = store.identity_key(self.charger_id, connector_value)
|
|
454
|
+
if previous_key != new_key:
|
|
455
|
+
existing_consumer = store.connections.get(new_key)
|
|
456
|
+
if existing_consumer is not None and existing_consumer is not self:
|
|
457
|
+
await existing_consumer.close()
|
|
458
|
+
store.reassign_identity(previous_key, new_key)
|
|
459
|
+
store.connections[new_key] = self
|
|
460
|
+
store.logs["charger"].setdefault(new_key, [])
|
|
461
|
+
connector_name = await sync_to_async(
|
|
462
|
+
lambda: self.charger.name or self.charger.charger_id
|
|
463
|
+
)()
|
|
464
|
+
store.register_log_name(new_key, connector_name, log_type="charger")
|
|
465
|
+
aggregate_name = ""
|
|
466
|
+
if self.aggregate_charger:
|
|
467
|
+
aggregate_name = await sync_to_async(
|
|
468
|
+
lambda: self.aggregate_charger.name or self.aggregate_charger.charger_id
|
|
469
|
+
)()
|
|
470
|
+
store.register_log_name(
|
|
471
|
+
store.identity_key(self.charger_id, None),
|
|
472
|
+
aggregate_name or self.charger_id,
|
|
473
|
+
log_type="charger",
|
|
474
|
+
)
|
|
475
|
+
self.store_key = new_key
|
|
476
|
+
self.connector_value = connector_value
|
|
477
|
+
|
|
478
|
+
def _ensure_console_reference(self) -> None:
|
|
479
|
+
"""Create or update a header reference for the connected charger."""
|
|
480
|
+
|
|
481
|
+
ip = (self.client_ip or "").strip()
|
|
482
|
+
serial = (self.charger_id or "").strip()
|
|
483
|
+
if not ip or not serial:
|
|
484
|
+
return
|
|
485
|
+
if host_is_local_loopback(ip):
|
|
486
|
+
return
|
|
487
|
+
host = ip
|
|
488
|
+
ports = scan_open_ports(host)
|
|
489
|
+
if ports:
|
|
490
|
+
ordered_ports = prioritise_ports(ports)
|
|
491
|
+
else:
|
|
492
|
+
ordered_ports = prioritise_ports([DEFAULT_CONSOLE_PORT])
|
|
493
|
+
port = ordered_ports[0] if ordered_ports else DEFAULT_CONSOLE_PORT
|
|
494
|
+
secure = port in HTTPS_PORTS
|
|
495
|
+
url = build_console_url(host, port, secure)
|
|
496
|
+
alt_text = f"{serial} Console"
|
|
497
|
+
reference = Reference.objects.filter(alt_text=alt_text).order_by("id").first()
|
|
498
|
+
if reference is None:
|
|
499
|
+
reference = Reference.objects.create(
|
|
500
|
+
alt_text=alt_text,
|
|
501
|
+
value=url,
|
|
502
|
+
show_in_header=True,
|
|
503
|
+
method="link",
|
|
504
|
+
)
|
|
505
|
+
updated_fields: list[str] = []
|
|
506
|
+
if reference.value != url:
|
|
507
|
+
reference.value = url
|
|
508
|
+
updated_fields.append("value")
|
|
509
|
+
if reference.method != "link":
|
|
510
|
+
reference.method = "link"
|
|
511
|
+
updated_fields.append("method")
|
|
512
|
+
if not reference.show_in_header:
|
|
513
|
+
reference.show_in_header = True
|
|
514
|
+
updated_fields.append("show_in_header")
|
|
515
|
+
if updated_fields:
|
|
516
|
+
reference.save(update_fields=updated_fields)
|
|
517
|
+
|
|
518
|
+
async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
|
|
519
|
+
"""Parse a MeterValues payload into MeterValue rows."""
|
|
520
|
+
connector_raw = payload.get("connectorId")
|
|
521
|
+
connector_value = None
|
|
522
|
+
if connector_raw is not None:
|
|
523
|
+
try:
|
|
524
|
+
connector_value = int(connector_raw)
|
|
525
|
+
except (TypeError, ValueError):
|
|
526
|
+
connector_value = None
|
|
527
|
+
await self._assign_connector(connector_value)
|
|
528
|
+
tx_id = payload.get("transactionId")
|
|
529
|
+
tx_obj = None
|
|
530
|
+
if tx_id is not None:
|
|
531
|
+
tx_obj = store.transactions.get(self.store_key)
|
|
532
|
+
if not tx_obj or tx_obj.pk != int(tx_id):
|
|
533
|
+
tx_obj = await database_sync_to_async(
|
|
534
|
+
Transaction.objects.filter(pk=tx_id, charger=self.charger).first
|
|
535
|
+
)()
|
|
536
|
+
if tx_obj is None:
|
|
537
|
+
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
538
|
+
pk=tx_id, charger=self.charger, start_time=timezone.now()
|
|
539
|
+
)
|
|
540
|
+
store.start_session_log(self.store_key, tx_obj.pk)
|
|
541
|
+
store.add_session_message(self.store_key, raw_message)
|
|
542
|
+
store.transactions[self.store_key] = tx_obj
|
|
543
|
+
else:
|
|
544
|
+
tx_obj = store.transactions.get(self.store_key)
|
|
545
|
+
|
|
546
|
+
readings = []
|
|
547
|
+
updated_fields: set[str] = set()
|
|
548
|
+
temperature = None
|
|
549
|
+
temp_unit = ""
|
|
550
|
+
for mv in payload.get("meterValue", []):
|
|
551
|
+
ts = parse_datetime(mv.get("timestamp"))
|
|
552
|
+
values: dict[str, Decimal] = {}
|
|
553
|
+
context = ""
|
|
554
|
+
for sv in mv.get("sampledValue", []):
|
|
555
|
+
try:
|
|
556
|
+
val = Decimal(str(sv.get("value")))
|
|
557
|
+
except Exception:
|
|
558
|
+
continue
|
|
559
|
+
context = sv.get("context", context or "")
|
|
560
|
+
measurand = sv.get("measurand", "")
|
|
561
|
+
unit = sv.get("unit", "")
|
|
562
|
+
field = None
|
|
563
|
+
if measurand in ("", "Energy.Active.Import.Register"):
|
|
564
|
+
field = "energy"
|
|
565
|
+
if unit == "Wh":
|
|
566
|
+
val = val / Decimal("1000")
|
|
567
|
+
elif measurand == "Voltage":
|
|
568
|
+
field = "voltage"
|
|
569
|
+
elif measurand == "Current.Import":
|
|
570
|
+
field = "current_import"
|
|
571
|
+
elif measurand == "Current.Offered":
|
|
572
|
+
field = "current_offered"
|
|
573
|
+
elif measurand == "Temperature":
|
|
574
|
+
field = "temperature"
|
|
575
|
+
temperature = val
|
|
576
|
+
temp_unit = unit
|
|
577
|
+
elif measurand == "SoC":
|
|
578
|
+
field = "soc"
|
|
579
|
+
if field:
|
|
580
|
+
if tx_obj and context in ("Transaction.Begin", "Transaction.End"):
|
|
581
|
+
suffix = "start" if context == "Transaction.Begin" else "stop"
|
|
582
|
+
if field == "energy":
|
|
583
|
+
mult = 1000 if unit in ("kW", "kWh") else 1
|
|
584
|
+
setattr(tx_obj, f"meter_{suffix}", int(val * mult))
|
|
585
|
+
updated_fields.add(f"meter_{suffix}")
|
|
586
|
+
else:
|
|
587
|
+
setattr(tx_obj, f"{field}_{suffix}", val)
|
|
588
|
+
updated_fields.add(f"{field}_{suffix}")
|
|
589
|
+
else:
|
|
590
|
+
values[field] = val
|
|
591
|
+
if tx_obj and field == "energy" and tx_obj.meter_start is None:
|
|
592
|
+
mult = 1000 if unit in ("kW", "kWh") else 1
|
|
593
|
+
try:
|
|
594
|
+
tx_obj.meter_start = int(val * mult)
|
|
595
|
+
except (TypeError, ValueError):
|
|
596
|
+
pass
|
|
597
|
+
else:
|
|
598
|
+
updated_fields.add("meter_start")
|
|
599
|
+
if values and context not in ("Transaction.Begin", "Transaction.End"):
|
|
600
|
+
readings.append(
|
|
601
|
+
MeterValue(
|
|
602
|
+
charger=self.charger,
|
|
603
|
+
connector_id=connector_value,
|
|
604
|
+
transaction=tx_obj,
|
|
605
|
+
timestamp=ts,
|
|
606
|
+
context=context,
|
|
607
|
+
**values,
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
if readings:
|
|
611
|
+
await database_sync_to_async(MeterValue.objects.bulk_create)(readings)
|
|
612
|
+
if tx_obj and updated_fields:
|
|
613
|
+
await database_sync_to_async(tx_obj.save)(
|
|
614
|
+
update_fields=list(updated_fields)
|
|
615
|
+
)
|
|
616
|
+
if connector_value is not None and not self.charger.connector_id:
|
|
617
|
+
self.charger.connector_id = connector_value
|
|
618
|
+
await database_sync_to_async(self.charger.save)(
|
|
619
|
+
update_fields=["connector_id"]
|
|
620
|
+
)
|
|
621
|
+
if temperature is not None:
|
|
622
|
+
self.charger.temperature = temperature
|
|
623
|
+
self.charger.temperature_unit = temp_unit
|
|
624
|
+
await database_sync_to_async(self.charger.save)(
|
|
625
|
+
update_fields=["temperature", "temperature_unit"]
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
async def _update_firmware_state(
|
|
629
|
+
self, status: str, status_info: str, timestamp: datetime | None
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Persist firmware status fields for the active charger identities."""
|
|
632
|
+
|
|
633
|
+
targets: list[Charger] = []
|
|
634
|
+
seen_ids: set[int] = set()
|
|
635
|
+
for charger in (self.charger, self.aggregate_charger):
|
|
636
|
+
if not charger or charger.pk is None:
|
|
637
|
+
continue
|
|
638
|
+
if charger.pk in seen_ids:
|
|
639
|
+
continue
|
|
640
|
+
targets.append(charger)
|
|
641
|
+
seen_ids.add(charger.pk)
|
|
642
|
+
|
|
643
|
+
if not targets:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
def _persist(ids: list[int]) -> None:
|
|
647
|
+
Charger.objects.filter(pk__in=ids).update(
|
|
648
|
+
firmware_status=status,
|
|
649
|
+
firmware_status_info=status_info,
|
|
650
|
+
firmware_timestamp=timestamp,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
await database_sync_to_async(_persist)([target.pk for target in targets])
|
|
654
|
+
for target in targets:
|
|
655
|
+
target.firmware_status = status
|
|
656
|
+
target.firmware_status_info = status_info
|
|
657
|
+
target.firmware_timestamp = timestamp
|
|
658
|
+
|
|
659
|
+
async def _cancel_consumption_message(self) -> None:
|
|
660
|
+
"""Stop any scheduled consumption message updates."""
|
|
661
|
+
|
|
662
|
+
task = self._consumption_task
|
|
663
|
+
self._consumption_task = None
|
|
664
|
+
if task:
|
|
665
|
+
task.cancel()
|
|
666
|
+
try:
|
|
667
|
+
await task
|
|
668
|
+
except asyncio.CancelledError:
|
|
669
|
+
pass
|
|
670
|
+
self._consumption_message_uuid = None
|
|
671
|
+
|
|
672
|
+
async def _update_consumption_message(self, tx_id: int) -> str | None:
|
|
673
|
+
"""Create or update the Net Message for an active transaction."""
|
|
674
|
+
|
|
675
|
+
existing_uuid = self._consumption_message_uuid
|
|
676
|
+
|
|
677
|
+
def _persist() -> str | None:
|
|
678
|
+
tx = (
|
|
679
|
+
Transaction.objects.select_related("charger")
|
|
680
|
+
.filter(pk=tx_id)
|
|
681
|
+
.first()
|
|
682
|
+
)
|
|
683
|
+
if not tx:
|
|
684
|
+
return None
|
|
685
|
+
charger = tx.charger or self.charger
|
|
686
|
+
serial = ""
|
|
687
|
+
if charger and charger.charger_id:
|
|
688
|
+
serial = charger.charger_id
|
|
689
|
+
elif self.charger_id:
|
|
690
|
+
serial = self.charger_id
|
|
691
|
+
serial = serial[:64]
|
|
692
|
+
if not serial:
|
|
693
|
+
return None
|
|
694
|
+
now_local = timezone.localtime(timezone.now())
|
|
695
|
+
body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
|
|
696
|
+
if existing_uuid:
|
|
697
|
+
msg = NetMessage.objects.filter(uuid=existing_uuid).first()
|
|
698
|
+
if msg:
|
|
699
|
+
msg.subject = serial
|
|
700
|
+
msg.body = body_value
|
|
701
|
+
msg.save(update_fields=["subject", "body"])
|
|
702
|
+
msg.propagate()
|
|
703
|
+
return str(msg.uuid)
|
|
704
|
+
msg = NetMessage.broadcast(subject=serial, body=body_value)
|
|
705
|
+
return str(msg.uuid)
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
result = await database_sync_to_async(_persist)()
|
|
709
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
710
|
+
store.add_log(
|
|
711
|
+
self.store_key,
|
|
712
|
+
f"Failed to broadcast consumption message: {exc}",
|
|
713
|
+
log_type="charger",
|
|
714
|
+
)
|
|
715
|
+
return None
|
|
716
|
+
if result is None:
|
|
717
|
+
store.add_log(
|
|
718
|
+
self.store_key,
|
|
719
|
+
"Unable to broadcast consumption message: missing data",
|
|
720
|
+
log_type="charger",
|
|
721
|
+
)
|
|
722
|
+
return None
|
|
723
|
+
self._consumption_message_uuid = result
|
|
724
|
+
return result
|
|
725
|
+
|
|
726
|
+
async def _consumption_message_loop(self, tx_id: int) -> None:
|
|
727
|
+
"""Periodically refresh the consumption Net Message."""
|
|
728
|
+
|
|
729
|
+
try:
|
|
730
|
+
while True:
|
|
731
|
+
await asyncio.sleep(self.consumption_update_interval)
|
|
732
|
+
updated = await self._update_consumption_message(tx_id)
|
|
733
|
+
if not updated:
|
|
734
|
+
break
|
|
735
|
+
except asyncio.CancelledError:
|
|
736
|
+
pass
|
|
737
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
738
|
+
store.add_log(
|
|
739
|
+
self.store_key,
|
|
740
|
+
f"Failed to refresh consumption message: {exc}",
|
|
741
|
+
log_type="charger",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
|
|
745
|
+
"""Send the initial consumption message and schedule updates."""
|
|
746
|
+
|
|
747
|
+
await self._cancel_consumption_message()
|
|
748
|
+
initial = await self._update_consumption_message(tx_obj.pk)
|
|
749
|
+
if not initial:
|
|
750
|
+
return
|
|
751
|
+
task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
|
|
752
|
+
task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
|
|
753
|
+
self._consumption_task = task
|
|
754
|
+
|
|
755
|
+
def _persist_configuration_result(
|
|
756
|
+
self, payload: dict, connector_hint: int | str | None
|
|
757
|
+
) -> ChargerConfiguration | None:
|
|
758
|
+
if not isinstance(payload, dict):
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
connector_value: int | None = None
|
|
762
|
+
if connector_hint not in (None, ""):
|
|
763
|
+
try:
|
|
764
|
+
connector_value = int(connector_hint)
|
|
765
|
+
except (TypeError, ValueError):
|
|
766
|
+
connector_value = None
|
|
767
|
+
|
|
768
|
+
normalized_entries: list[dict[str, object]] = []
|
|
769
|
+
for entry in payload.get("configurationKey") or []:
|
|
770
|
+
if not isinstance(entry, dict):
|
|
771
|
+
continue
|
|
772
|
+
key = str(entry.get("key") or "")
|
|
773
|
+
normalized: dict[str, object] = {"key": key}
|
|
774
|
+
if "value" in entry:
|
|
775
|
+
normalized["value"] = entry.get("value")
|
|
776
|
+
normalized["readonly"] = bool(entry.get("readonly"))
|
|
777
|
+
normalized_entries.append(normalized)
|
|
778
|
+
|
|
779
|
+
unknown_values: list[str] = []
|
|
780
|
+
for value in payload.get("unknownKey") or []:
|
|
781
|
+
if value is None:
|
|
782
|
+
continue
|
|
783
|
+
unknown_values.append(str(value))
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
|
|
787
|
+
except (TypeError, ValueError):
|
|
788
|
+
raw_payload = payload
|
|
789
|
+
|
|
790
|
+
configuration = ChargerConfiguration.objects.create(
|
|
791
|
+
charger_identifier=self.charger_id,
|
|
792
|
+
connector_id=connector_value,
|
|
793
|
+
configuration_keys=normalized_entries,
|
|
794
|
+
unknown_keys=unknown_values,
|
|
795
|
+
evcs_snapshot_at=timezone.now(),
|
|
796
|
+
raw_payload=raw_payload,
|
|
797
|
+
)
|
|
798
|
+
Charger.objects.filter(charger_id=self.charger_id).update(
|
|
799
|
+
configuration=configuration
|
|
800
|
+
)
|
|
801
|
+
return configuration
|
|
802
|
+
|
|
803
|
+
async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
|
|
804
|
+
metadata = store.pop_pending_call(message_id)
|
|
805
|
+
if not metadata:
|
|
806
|
+
return
|
|
807
|
+
if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
|
|
808
|
+
return
|
|
809
|
+
action = metadata.get("action")
|
|
810
|
+
log_key = metadata.get("log_key") or self.store_key
|
|
811
|
+
payload_data = payload if isinstance(payload, dict) else {}
|
|
812
|
+
if action == "DataTransfer":
|
|
813
|
+
message_pk = metadata.get("message_pk")
|
|
814
|
+
if not message_pk:
|
|
815
|
+
store.record_pending_call_result(
|
|
816
|
+
message_id,
|
|
817
|
+
metadata=metadata,
|
|
818
|
+
payload=payload_data,
|
|
819
|
+
)
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
def _apply():
|
|
823
|
+
message = DataTransferMessage.objects.filter(pk=message_pk).first()
|
|
824
|
+
if not message:
|
|
825
|
+
return
|
|
826
|
+
status_value = str((payload or {}).get("status") or "").strip()
|
|
827
|
+
message.status = status_value
|
|
828
|
+
message.response_data = (payload or {}).get("data")
|
|
829
|
+
message.error_code = ""
|
|
830
|
+
message.error_description = ""
|
|
831
|
+
message.error_details = None
|
|
832
|
+
message.responded_at = timezone.now()
|
|
833
|
+
message.save(
|
|
834
|
+
update_fields=[
|
|
835
|
+
"status",
|
|
836
|
+
"response_data",
|
|
837
|
+
"error_code",
|
|
838
|
+
"error_description",
|
|
839
|
+
"error_details",
|
|
840
|
+
"responded_at",
|
|
841
|
+
"updated_at",
|
|
842
|
+
]
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
await database_sync_to_async(_apply)()
|
|
846
|
+
store.record_pending_call_result(
|
|
847
|
+
message_id,
|
|
848
|
+
metadata=metadata,
|
|
849
|
+
payload=payload_data,
|
|
850
|
+
)
|
|
851
|
+
return
|
|
852
|
+
if action == "GetConfiguration":
|
|
853
|
+
try:
|
|
854
|
+
payload_text = json.dumps(
|
|
855
|
+
payload_data, sort_keys=True, ensure_ascii=False
|
|
856
|
+
)
|
|
857
|
+
except TypeError:
|
|
858
|
+
payload_text = str(payload_data)
|
|
859
|
+
store.add_log(
|
|
860
|
+
log_key,
|
|
861
|
+
f"GetConfiguration result: {payload_text}",
|
|
862
|
+
log_type="charger",
|
|
863
|
+
)
|
|
864
|
+
configuration = await database_sync_to_async(
|
|
865
|
+
self._persist_configuration_result
|
|
866
|
+
)(payload_data, metadata.get("connector_id"))
|
|
867
|
+
if configuration:
|
|
868
|
+
if self.charger and self.charger.charger_id == self.charger_id:
|
|
869
|
+
self.charger.configuration = configuration
|
|
870
|
+
if (
|
|
871
|
+
self.aggregate_charger
|
|
872
|
+
and self.aggregate_charger.charger_id == self.charger_id
|
|
873
|
+
):
|
|
874
|
+
self.aggregate_charger.configuration = configuration
|
|
875
|
+
store.record_pending_call_result(
|
|
876
|
+
message_id,
|
|
877
|
+
metadata=metadata,
|
|
878
|
+
payload=payload_data,
|
|
879
|
+
)
|
|
880
|
+
return
|
|
881
|
+
if action == "TriggerMessage":
|
|
882
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
883
|
+
target = metadata.get("trigger_target") or metadata.get("follow_up_action")
|
|
884
|
+
connector_value = metadata.get("trigger_connector")
|
|
885
|
+
message = "TriggerMessage result"
|
|
886
|
+
if target:
|
|
887
|
+
message = f"TriggerMessage {target} result"
|
|
888
|
+
if status_value:
|
|
889
|
+
message += f": status={status_value}"
|
|
890
|
+
if connector_value:
|
|
891
|
+
message += f", connector={connector_value}"
|
|
892
|
+
store.add_log(log_key, message, log_type="charger")
|
|
893
|
+
if status_value == "Accepted" and target:
|
|
894
|
+
store.register_triggered_followup(
|
|
895
|
+
self.charger_id,
|
|
896
|
+
str(target),
|
|
897
|
+
connector=connector_value,
|
|
898
|
+
log_key=log_key,
|
|
899
|
+
target=str(target),
|
|
900
|
+
)
|
|
901
|
+
store.record_pending_call_result(
|
|
902
|
+
message_id,
|
|
903
|
+
metadata=metadata,
|
|
904
|
+
payload=payload_data,
|
|
905
|
+
)
|
|
906
|
+
return
|
|
907
|
+
if action == "ReserveNow":
|
|
908
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
909
|
+
message = "ReserveNow result"
|
|
910
|
+
if status_value:
|
|
911
|
+
message += f": status={status_value}"
|
|
912
|
+
store.add_log(log_key, message, log_type="charger")
|
|
913
|
+
|
|
914
|
+
reservation_pk = metadata.get("reservation_pk")
|
|
915
|
+
|
|
916
|
+
def _apply():
|
|
917
|
+
if not reservation_pk:
|
|
918
|
+
return
|
|
919
|
+
reservation = CPReservation.objects.filter(pk=reservation_pk).first()
|
|
920
|
+
if not reservation:
|
|
921
|
+
return
|
|
922
|
+
reservation.evcs_status = status_value
|
|
923
|
+
reservation.evcs_error = ""
|
|
924
|
+
confirmed = status_value.casefold() == "accepted"
|
|
925
|
+
reservation.evcs_confirmed = confirmed
|
|
926
|
+
reservation.evcs_confirmed_at = timezone.now() if confirmed else None
|
|
927
|
+
reservation.save(
|
|
928
|
+
update_fields=[
|
|
929
|
+
"evcs_status",
|
|
930
|
+
"evcs_error",
|
|
931
|
+
"evcs_confirmed",
|
|
932
|
+
"evcs_confirmed_at",
|
|
933
|
+
"updated_on",
|
|
934
|
+
]
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
await database_sync_to_async(_apply)()
|
|
938
|
+
store.record_pending_call_result(
|
|
939
|
+
message_id,
|
|
940
|
+
metadata=metadata,
|
|
941
|
+
payload=payload_data,
|
|
942
|
+
)
|
|
943
|
+
return
|
|
944
|
+
if action == "RemoteStartTransaction":
|
|
945
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
946
|
+
message = "RemoteStartTransaction result"
|
|
947
|
+
if status_value:
|
|
948
|
+
message += f": status={status_value}"
|
|
949
|
+
store.add_log(log_key, message, log_type="charger")
|
|
950
|
+
store.record_pending_call_result(
|
|
951
|
+
message_id,
|
|
952
|
+
metadata=metadata,
|
|
953
|
+
payload=payload_data,
|
|
954
|
+
)
|
|
955
|
+
return
|
|
956
|
+
if action == "RemoteStopTransaction":
|
|
957
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
958
|
+
message = "RemoteStopTransaction result"
|
|
959
|
+
if status_value:
|
|
960
|
+
message += f": status={status_value}"
|
|
961
|
+
store.add_log(log_key, message, log_type="charger")
|
|
962
|
+
store.record_pending_call_result(
|
|
963
|
+
message_id,
|
|
964
|
+
metadata=metadata,
|
|
965
|
+
payload=payload_data,
|
|
966
|
+
)
|
|
967
|
+
return
|
|
968
|
+
if action == "Reset":
|
|
969
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
970
|
+
message = "Reset result"
|
|
971
|
+
if status_value:
|
|
972
|
+
message += f": status={status_value}"
|
|
973
|
+
store.add_log(log_key, message, log_type="charger")
|
|
974
|
+
store.record_pending_call_result(
|
|
975
|
+
message_id,
|
|
976
|
+
metadata=metadata,
|
|
977
|
+
payload=payload_data,
|
|
978
|
+
)
|
|
979
|
+
return
|
|
980
|
+
if action != "ChangeAvailability":
|
|
981
|
+
store.record_pending_call_result(
|
|
982
|
+
message_id,
|
|
983
|
+
metadata=metadata,
|
|
984
|
+
payload=payload_data,
|
|
985
|
+
)
|
|
986
|
+
return
|
|
987
|
+
status = str((payload or {}).get("status") or "").strip()
|
|
988
|
+
requested_type = metadata.get("availability_type")
|
|
989
|
+
connector_value = metadata.get("connector_id")
|
|
990
|
+
requested_at = metadata.get("requested_at")
|
|
991
|
+
await self._update_change_availability_state(
|
|
992
|
+
connector_value,
|
|
993
|
+
requested_type,
|
|
994
|
+
status,
|
|
995
|
+
requested_at,
|
|
996
|
+
details="",
|
|
997
|
+
)
|
|
998
|
+
store.record_pending_call_result(
|
|
999
|
+
message_id,
|
|
1000
|
+
metadata=metadata,
|
|
1001
|
+
payload=payload_data,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
async def _handle_call_error(
|
|
1005
|
+
self,
|
|
1006
|
+
message_id: str,
|
|
1007
|
+
error_code: str | None,
|
|
1008
|
+
description: str | None,
|
|
1009
|
+
details: dict | None,
|
|
1010
|
+
) -> None:
|
|
1011
|
+
metadata = store.pop_pending_call(message_id)
|
|
1012
|
+
if not metadata:
|
|
1013
|
+
return
|
|
1014
|
+
if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
|
|
1015
|
+
return
|
|
1016
|
+
action = metadata.get("action")
|
|
1017
|
+
log_key = metadata.get("log_key") or self.store_key
|
|
1018
|
+
if action == "DataTransfer":
|
|
1019
|
+
message_pk = metadata.get("message_pk")
|
|
1020
|
+
if not message_pk:
|
|
1021
|
+
store.record_pending_call_result(
|
|
1022
|
+
message_id,
|
|
1023
|
+
metadata=metadata,
|
|
1024
|
+
success=False,
|
|
1025
|
+
error_code=error_code,
|
|
1026
|
+
error_description=description,
|
|
1027
|
+
error_details=details,
|
|
1028
|
+
)
|
|
1029
|
+
return
|
|
1030
|
+
|
|
1031
|
+
def _apply():
|
|
1032
|
+
message = DataTransferMessage.objects.filter(pk=message_pk).first()
|
|
1033
|
+
if not message:
|
|
1034
|
+
return
|
|
1035
|
+
status_value = (error_code or "Error").strip() or "Error"
|
|
1036
|
+
message.status = status_value
|
|
1037
|
+
message.response_data = None
|
|
1038
|
+
message.error_code = (error_code or "").strip()
|
|
1039
|
+
message.error_description = (description or "").strip()
|
|
1040
|
+
message.error_details = details
|
|
1041
|
+
message.responded_at = timezone.now()
|
|
1042
|
+
message.save(
|
|
1043
|
+
update_fields=[
|
|
1044
|
+
"status",
|
|
1045
|
+
"response_data",
|
|
1046
|
+
"error_code",
|
|
1047
|
+
"error_description",
|
|
1048
|
+
"error_details",
|
|
1049
|
+
"responded_at",
|
|
1050
|
+
"updated_at",
|
|
1051
|
+
]
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
await database_sync_to_async(_apply)()
|
|
1055
|
+
store.record_pending_call_result(
|
|
1056
|
+
message_id,
|
|
1057
|
+
metadata=metadata,
|
|
1058
|
+
success=False,
|
|
1059
|
+
error_code=error_code,
|
|
1060
|
+
error_description=description,
|
|
1061
|
+
error_details=details,
|
|
1062
|
+
)
|
|
1063
|
+
return
|
|
1064
|
+
if action == "GetConfiguration":
|
|
1065
|
+
parts: list[str] = []
|
|
1066
|
+
code_text = (error_code or "").strip()
|
|
1067
|
+
if code_text:
|
|
1068
|
+
parts.append(f"code={code_text}")
|
|
1069
|
+
description_text = (description or "").strip()
|
|
1070
|
+
if description_text:
|
|
1071
|
+
parts.append(f"description={description_text}")
|
|
1072
|
+
if details:
|
|
1073
|
+
try:
|
|
1074
|
+
details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
|
1075
|
+
except TypeError:
|
|
1076
|
+
details_text = str(details)
|
|
1077
|
+
if details_text:
|
|
1078
|
+
parts.append(f"details={details_text}")
|
|
1079
|
+
if parts:
|
|
1080
|
+
message = "GetConfiguration error: " + ", ".join(parts)
|
|
1081
|
+
else:
|
|
1082
|
+
message = "GetConfiguration error"
|
|
1083
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1084
|
+
store.record_pending_call_result(
|
|
1085
|
+
message_id,
|
|
1086
|
+
metadata=metadata,
|
|
1087
|
+
success=False,
|
|
1088
|
+
error_code=error_code,
|
|
1089
|
+
error_description=description,
|
|
1090
|
+
error_details=details,
|
|
1091
|
+
)
|
|
1092
|
+
return
|
|
1093
|
+
if action == "TriggerMessage":
|
|
1094
|
+
target = metadata.get("trigger_target") or metadata.get("follow_up_action")
|
|
1095
|
+
connector_value = metadata.get("trigger_connector")
|
|
1096
|
+
parts: list[str] = []
|
|
1097
|
+
if error_code:
|
|
1098
|
+
parts.append(f"code={str(error_code).strip()}")
|
|
1099
|
+
if description:
|
|
1100
|
+
parts.append(f"description={str(description).strip()}")
|
|
1101
|
+
if details:
|
|
1102
|
+
try:
|
|
1103
|
+
parts.append(
|
|
1104
|
+
"details="
|
|
1105
|
+
+ json.dumps(details, sort_keys=True, ensure_ascii=False)
|
|
1106
|
+
)
|
|
1107
|
+
except TypeError:
|
|
1108
|
+
parts.append(f"details={details}")
|
|
1109
|
+
label = f"TriggerMessage {target}" if target else "TriggerMessage"
|
|
1110
|
+
message = label + " error"
|
|
1111
|
+
if parts:
|
|
1112
|
+
message += ": " + ", ".join(parts)
|
|
1113
|
+
if connector_value:
|
|
1114
|
+
message += f", connector={connector_value}"
|
|
1115
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1116
|
+
store.record_pending_call_result(
|
|
1117
|
+
message_id,
|
|
1118
|
+
metadata=metadata,
|
|
1119
|
+
success=False,
|
|
1120
|
+
error_code=error_code,
|
|
1121
|
+
error_description=description,
|
|
1122
|
+
error_details=details,
|
|
1123
|
+
)
|
|
1124
|
+
return
|
|
1125
|
+
if action == "ReserveNow":
|
|
1126
|
+
parts: list[str] = []
|
|
1127
|
+
code_text = (error_code or "").strip() if error_code else ""
|
|
1128
|
+
if code_text:
|
|
1129
|
+
parts.append(f"code={code_text}")
|
|
1130
|
+
description_text = (description or "").strip() if description else ""
|
|
1131
|
+
if description_text:
|
|
1132
|
+
parts.append(f"description={description_text}")
|
|
1133
|
+
details_text = ""
|
|
1134
|
+
if details:
|
|
1135
|
+
try:
|
|
1136
|
+
details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
|
1137
|
+
except TypeError:
|
|
1138
|
+
details_text = str(details)
|
|
1139
|
+
if details_text:
|
|
1140
|
+
parts.append(f"details={details_text}")
|
|
1141
|
+
message = "ReserveNow error"
|
|
1142
|
+
if parts:
|
|
1143
|
+
message += ": " + ", ".join(parts)
|
|
1144
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1145
|
+
|
|
1146
|
+
reservation_pk = metadata.get("reservation_pk")
|
|
1147
|
+
|
|
1148
|
+
def _apply():
|
|
1149
|
+
if not reservation_pk:
|
|
1150
|
+
return
|
|
1151
|
+
reservation = CPReservation.objects.filter(pk=reservation_pk).first()
|
|
1152
|
+
if not reservation:
|
|
1153
|
+
return
|
|
1154
|
+
summary_parts = []
|
|
1155
|
+
if code_text:
|
|
1156
|
+
summary_parts.append(code_text)
|
|
1157
|
+
if description_text:
|
|
1158
|
+
summary_parts.append(description_text)
|
|
1159
|
+
if details_text:
|
|
1160
|
+
summary_parts.append(details_text)
|
|
1161
|
+
reservation.evcs_status = ""
|
|
1162
|
+
reservation.evcs_error = "; ".join(summary_parts)
|
|
1163
|
+
reservation.evcs_confirmed = False
|
|
1164
|
+
reservation.evcs_confirmed_at = None
|
|
1165
|
+
reservation.save(
|
|
1166
|
+
update_fields=[
|
|
1167
|
+
"evcs_status",
|
|
1168
|
+
"evcs_error",
|
|
1169
|
+
"evcs_confirmed",
|
|
1170
|
+
"evcs_confirmed_at",
|
|
1171
|
+
"updated_on",
|
|
1172
|
+
]
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
await database_sync_to_async(_apply)()
|
|
1176
|
+
store.record_pending_call_result(
|
|
1177
|
+
message_id,
|
|
1178
|
+
metadata=metadata,
|
|
1179
|
+
success=False,
|
|
1180
|
+
error_code=error_code,
|
|
1181
|
+
error_description=description,
|
|
1182
|
+
error_details=details,
|
|
1183
|
+
)
|
|
1184
|
+
return
|
|
1185
|
+
if action == "RemoteStartTransaction":
|
|
1186
|
+
message = "RemoteStartTransaction error"
|
|
1187
|
+
if error_code:
|
|
1188
|
+
message += f": code={str(error_code).strip()}"
|
|
1189
|
+
if description:
|
|
1190
|
+
suffix = str(description).strip()
|
|
1191
|
+
if suffix:
|
|
1192
|
+
message += f", description={suffix}"
|
|
1193
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1194
|
+
store.record_pending_call_result(
|
|
1195
|
+
message_id,
|
|
1196
|
+
metadata=metadata,
|
|
1197
|
+
success=False,
|
|
1198
|
+
error_code=error_code,
|
|
1199
|
+
error_description=description,
|
|
1200
|
+
error_details=details,
|
|
1201
|
+
)
|
|
1202
|
+
return
|
|
1203
|
+
if action == "RemoteStopTransaction":
|
|
1204
|
+
message = "RemoteStopTransaction error"
|
|
1205
|
+
if error_code:
|
|
1206
|
+
message += f": code={str(error_code).strip()}"
|
|
1207
|
+
if description:
|
|
1208
|
+
suffix = str(description).strip()
|
|
1209
|
+
if suffix:
|
|
1210
|
+
message += f", description={suffix}"
|
|
1211
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1212
|
+
store.record_pending_call_result(
|
|
1213
|
+
message_id,
|
|
1214
|
+
metadata=metadata,
|
|
1215
|
+
success=False,
|
|
1216
|
+
error_code=error_code,
|
|
1217
|
+
error_description=description,
|
|
1218
|
+
error_details=details,
|
|
1219
|
+
)
|
|
1220
|
+
return
|
|
1221
|
+
if action == "Reset":
|
|
1222
|
+
message = "Reset error"
|
|
1223
|
+
if error_code:
|
|
1224
|
+
message += f": code={str(error_code).strip()}"
|
|
1225
|
+
if description:
|
|
1226
|
+
suffix = str(description).strip()
|
|
1227
|
+
if suffix:
|
|
1228
|
+
message += f", description={suffix}"
|
|
1229
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1230
|
+
store.record_pending_call_result(
|
|
1231
|
+
message_id,
|
|
1232
|
+
metadata=metadata,
|
|
1233
|
+
success=False,
|
|
1234
|
+
error_code=error_code,
|
|
1235
|
+
error_description=description,
|
|
1236
|
+
error_details=details,
|
|
1237
|
+
)
|
|
1238
|
+
return
|
|
1239
|
+
if action != "ChangeAvailability":
|
|
1240
|
+
store.record_pending_call_result(
|
|
1241
|
+
message_id,
|
|
1242
|
+
metadata=metadata,
|
|
1243
|
+
success=False,
|
|
1244
|
+
error_code=error_code,
|
|
1245
|
+
error_description=description,
|
|
1246
|
+
error_details=details,
|
|
1247
|
+
)
|
|
1248
|
+
return
|
|
1249
|
+
detail_text = (description or "").strip()
|
|
1250
|
+
if not detail_text and details:
|
|
1251
|
+
try:
|
|
1252
|
+
detail_text = json.dumps(details, sort_keys=True)
|
|
1253
|
+
except Exception:
|
|
1254
|
+
detail_text = str(details)
|
|
1255
|
+
if not detail_text:
|
|
1256
|
+
detail_text = (error_code or "").strip() or "Error"
|
|
1257
|
+
requested_type = metadata.get("availability_type")
|
|
1258
|
+
connector_value = metadata.get("connector_id")
|
|
1259
|
+
requested_at = metadata.get("requested_at")
|
|
1260
|
+
await self._update_change_availability_state(
|
|
1261
|
+
connector_value,
|
|
1262
|
+
requested_type,
|
|
1263
|
+
"Rejected",
|
|
1264
|
+
requested_at,
|
|
1265
|
+
details=detail_text,
|
|
1266
|
+
)
|
|
1267
|
+
store.record_pending_call_result(
|
|
1268
|
+
message_id,
|
|
1269
|
+
metadata=metadata,
|
|
1270
|
+
success=False,
|
|
1271
|
+
error_code=error_code,
|
|
1272
|
+
error_description=description,
|
|
1273
|
+
error_details=details,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
async def _handle_data_transfer(
|
|
1277
|
+
self, message_id: str, payload: dict | None
|
|
1278
|
+
) -> dict[str, object]:
|
|
1279
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
1280
|
+
vendor_id = str(payload.get("vendorId") or "").strip()
|
|
1281
|
+
vendor_message_id = payload.get("messageId")
|
|
1282
|
+
if vendor_message_id is None:
|
|
1283
|
+
vendor_message_id_text = ""
|
|
1284
|
+
elif isinstance(vendor_message_id, str):
|
|
1285
|
+
vendor_message_id_text = vendor_message_id.strip()
|
|
1286
|
+
else:
|
|
1287
|
+
vendor_message_id_text = str(vendor_message_id)
|
|
1288
|
+
connector_value = self.connector_value
|
|
1289
|
+
|
|
1290
|
+
def _get_or_create_charger():
|
|
1291
|
+
if self.charger and getattr(self.charger, "pk", None):
|
|
1292
|
+
return self.charger
|
|
1293
|
+
if connector_value is None:
|
|
1294
|
+
charger, _ = Charger.objects.get_or_create(
|
|
1295
|
+
charger_id=self.charger_id,
|
|
1296
|
+
connector_id=None,
|
|
1297
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
1298
|
+
)
|
|
1299
|
+
return charger
|
|
1300
|
+
charger, _ = Charger.objects.get_or_create(
|
|
1301
|
+
charger_id=self.charger_id,
|
|
1302
|
+
connector_id=connector_value,
|
|
1303
|
+
defaults={"last_path": self.scope.get("path", "")},
|
|
1304
|
+
)
|
|
1305
|
+
return charger
|
|
1306
|
+
|
|
1307
|
+
charger_obj = await database_sync_to_async(_get_or_create_charger)()
|
|
1308
|
+
message = await database_sync_to_async(DataTransferMessage.objects.create)(
|
|
1309
|
+
charger=charger_obj,
|
|
1310
|
+
connector_id=connector_value,
|
|
1311
|
+
direction=DataTransferMessage.DIRECTION_CP_TO_CSMS,
|
|
1312
|
+
ocpp_message_id=message_id,
|
|
1313
|
+
vendor_id=vendor_id,
|
|
1314
|
+
message_id=vendor_message_id_text,
|
|
1315
|
+
payload=payload or {},
|
|
1316
|
+
status="Pending",
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
status = "Rejected" if not vendor_id else "UnknownVendorId"
|
|
1320
|
+
response_data = None
|
|
1321
|
+
error_code = ""
|
|
1322
|
+
error_description = ""
|
|
1323
|
+
error_details = None
|
|
1324
|
+
|
|
1325
|
+
handler = self._resolve_data_transfer_handler(vendor_id) if vendor_id else None
|
|
1326
|
+
if handler:
|
|
1327
|
+
try:
|
|
1328
|
+
result = handler(message, payload)
|
|
1329
|
+
if inspect.isawaitable(result):
|
|
1330
|
+
result = await result
|
|
1331
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
1332
|
+
status = "Rejected"
|
|
1333
|
+
error_code = "InternalError"
|
|
1334
|
+
error_description = str(exc)
|
|
1335
|
+
else:
|
|
1336
|
+
if isinstance(result, tuple):
|
|
1337
|
+
status = str(result[0]) if result else status
|
|
1338
|
+
if len(result) > 1:
|
|
1339
|
+
response_data = result[1]
|
|
1340
|
+
elif isinstance(result, dict):
|
|
1341
|
+
status = str(result.get("status", status))
|
|
1342
|
+
if "data" in result:
|
|
1343
|
+
response_data = result["data"]
|
|
1344
|
+
elif isinstance(result, str):
|
|
1345
|
+
status = result
|
|
1346
|
+
final_status = status or "Rejected"
|
|
1347
|
+
|
|
1348
|
+
def _finalise():
|
|
1349
|
+
DataTransferMessage.objects.filter(pk=message.pk).update(
|
|
1350
|
+
status=final_status,
|
|
1351
|
+
response_data=response_data,
|
|
1352
|
+
error_code=error_code,
|
|
1353
|
+
error_description=error_description,
|
|
1354
|
+
error_details=error_details,
|
|
1355
|
+
responded_at=timezone.now(),
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
await database_sync_to_async(_finalise)()
|
|
1359
|
+
|
|
1360
|
+
reply_payload: dict[str, object] = {"status": final_status}
|
|
1361
|
+
if response_data is not None:
|
|
1362
|
+
reply_payload["data"] = response_data
|
|
1363
|
+
return reply_payload
|
|
1364
|
+
|
|
1365
|
+
def _resolve_data_transfer_handler(self, vendor_id: str):
|
|
1366
|
+
if not vendor_id:
|
|
1367
|
+
return None
|
|
1368
|
+
candidate = f"handle_data_transfer_{vendor_id.lower()}"
|
|
1369
|
+
return getattr(self, candidate, None)
|
|
1370
|
+
|
|
1371
|
+
async def _update_change_availability_state(
|
|
1372
|
+
self,
|
|
1373
|
+
connector_value: int | None,
|
|
1374
|
+
requested_type: str | None,
|
|
1375
|
+
status: str,
|
|
1376
|
+
requested_at,
|
|
1377
|
+
*,
|
|
1378
|
+
details: str = "",
|
|
1379
|
+
) -> None:
|
|
1380
|
+
status_value = status or ""
|
|
1381
|
+
now = timezone.now()
|
|
1382
|
+
|
|
1383
|
+
def _apply():
|
|
1384
|
+
filters: dict[str, object] = {"charger_id": self.charger_id}
|
|
1385
|
+
if connector_value is None:
|
|
1386
|
+
filters["connector_id__isnull"] = True
|
|
1387
|
+
else:
|
|
1388
|
+
filters["connector_id"] = connector_value
|
|
1389
|
+
targets = list(Charger.objects.filter(**filters))
|
|
1390
|
+
if not targets:
|
|
1391
|
+
return
|
|
1392
|
+
for target in targets:
|
|
1393
|
+
updates: dict[str, object] = {
|
|
1394
|
+
"availability_request_status": status_value,
|
|
1395
|
+
"availability_request_status_at": now,
|
|
1396
|
+
"availability_request_details": details,
|
|
1397
|
+
}
|
|
1398
|
+
if requested_type:
|
|
1399
|
+
updates["availability_requested_state"] = requested_type
|
|
1400
|
+
if requested_at:
|
|
1401
|
+
updates["availability_requested_at"] = requested_at
|
|
1402
|
+
elif requested_type:
|
|
1403
|
+
updates["availability_requested_at"] = now
|
|
1404
|
+
if status_value == "Accepted" and requested_type:
|
|
1405
|
+
updates["availability_state"] = requested_type
|
|
1406
|
+
updates["availability_state_updated_at"] = now
|
|
1407
|
+
Charger.objects.filter(pk=target.pk).update(**updates)
|
|
1408
|
+
for field, value in updates.items():
|
|
1409
|
+
setattr(target, field, value)
|
|
1410
|
+
if self.charger and self.charger.pk == target.pk:
|
|
1411
|
+
for field, value in updates.items():
|
|
1412
|
+
setattr(self.charger, field, value)
|
|
1413
|
+
if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
|
|
1414
|
+
for field, value in updates.items():
|
|
1415
|
+
setattr(self.aggregate_charger, field, value)
|
|
1416
|
+
|
|
1417
|
+
await database_sync_to_async(_apply)()
|
|
1418
|
+
|
|
1419
|
+
async def _update_availability_state(
|
|
1420
|
+
self,
|
|
1421
|
+
state: str,
|
|
1422
|
+
timestamp: datetime,
|
|
1423
|
+
connector_value: int | None,
|
|
1424
|
+
) -> None:
|
|
1425
|
+
def _apply():
|
|
1426
|
+
filters: dict[str, object] = {"charger_id": self.charger_id}
|
|
1427
|
+
if connector_value is None:
|
|
1428
|
+
filters["connector_id__isnull"] = True
|
|
1429
|
+
else:
|
|
1430
|
+
filters["connector_id"] = connector_value
|
|
1431
|
+
updates = {
|
|
1432
|
+
"availability_state": state,
|
|
1433
|
+
"availability_state_updated_at": timestamp,
|
|
1434
|
+
}
|
|
1435
|
+
targets = list(Charger.objects.filter(**filters))
|
|
1436
|
+
if not targets:
|
|
1437
|
+
return
|
|
1438
|
+
Charger.objects.filter(pk__in=[target.pk for target in targets]).update(
|
|
1439
|
+
**updates
|
|
1440
|
+
)
|
|
1441
|
+
for target in targets:
|
|
1442
|
+
for field, value in updates.items():
|
|
1443
|
+
setattr(target, field, value)
|
|
1444
|
+
if self.charger and self.charger.pk == target.pk:
|
|
1445
|
+
for field, value in updates.items():
|
|
1446
|
+
setattr(self.charger, field, value)
|
|
1447
|
+
if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
|
|
1448
|
+
for field, value in updates.items():
|
|
1449
|
+
setattr(self.aggregate_charger, field, value)
|
|
1450
|
+
|
|
1451
|
+
await database_sync_to_async(_apply)()
|
|
1452
|
+
|
|
1453
|
+
async def disconnect(self, close_code):
|
|
1454
|
+
store.release_ip_connection(getattr(self, "client_ip", None), self)
|
|
1455
|
+
tx_obj = None
|
|
1456
|
+
if self.charger_id:
|
|
1457
|
+
tx_obj = store.get_transaction(self.charger_id, self.connector_value)
|
|
1458
|
+
if tx_obj:
|
|
1459
|
+
await self._update_consumption_message(tx_obj.pk)
|
|
1460
|
+
await self._cancel_consumption_message()
|
|
1461
|
+
store.connections.pop(self.store_key, None)
|
|
1462
|
+
pending_key = store.pending_key(self.charger_id)
|
|
1463
|
+
if self.store_key != pending_key:
|
|
1464
|
+
store.connections.pop(pending_key, None)
|
|
1465
|
+
store.end_session_log(self.store_key)
|
|
1466
|
+
store.stop_session_lock()
|
|
1467
|
+
store.clear_pending_calls(self.charger_id)
|
|
1468
|
+
store.add_log(self.store_key, f"Closed (code={close_code})", log_type="charger")
|
|
1469
|
+
|
|
1470
|
+
async def receive(self, text_data=None, bytes_data=None):
|
|
1471
|
+
raw = text_data
|
|
1472
|
+
if raw is None and bytes_data is not None:
|
|
1473
|
+
raw = base64.b64encode(bytes_data).decode("ascii")
|
|
1474
|
+
if raw is None:
|
|
1475
|
+
return
|
|
1476
|
+
store.add_log(self.store_key, raw, log_type="charger")
|
|
1477
|
+
store.add_session_message(self.store_key, raw)
|
|
1478
|
+
try:
|
|
1479
|
+
msg = json.loads(raw)
|
|
1480
|
+
except json.JSONDecodeError:
|
|
1481
|
+
return
|
|
1482
|
+
if not isinstance(msg, list) or not msg:
|
|
1483
|
+
return
|
|
1484
|
+
message_type = msg[0]
|
|
1485
|
+
if message_type == 2:
|
|
1486
|
+
msg_id, action = msg[1], msg[2]
|
|
1487
|
+
payload = msg[3] if len(msg) > 3 else {}
|
|
1488
|
+
reply_payload = {}
|
|
1489
|
+
connector_hint = None
|
|
1490
|
+
if isinstance(payload, dict):
|
|
1491
|
+
connector_hint = payload.get("connectorId")
|
|
1492
|
+
follow_up = store.consume_triggered_followup(
|
|
1493
|
+
self.charger_id, action, connector_hint
|
|
1494
|
+
)
|
|
1495
|
+
if follow_up:
|
|
1496
|
+
follow_up_log_key = follow_up.get("log_key") or self.store_key
|
|
1497
|
+
target_label = follow_up.get("target") or action
|
|
1498
|
+
connector_slug_value = follow_up.get("connector")
|
|
1499
|
+
suffix = ""
|
|
1500
|
+
if (
|
|
1501
|
+
connector_slug_value
|
|
1502
|
+
and connector_slug_value != store.AGGREGATE_SLUG
|
|
1503
|
+
):
|
|
1504
|
+
suffix = f" (connector {connector_slug_value})"
|
|
1505
|
+
store.add_log(
|
|
1506
|
+
follow_up_log_key,
|
|
1507
|
+
f"TriggerMessage follow-up received: {target_label}{suffix}",
|
|
1508
|
+
log_type="charger",
|
|
1509
|
+
)
|
|
1510
|
+
await self._assign_connector(payload.get("connectorId"))
|
|
1511
|
+
if action == "BootNotification":
|
|
1512
|
+
reply_payload = {
|
|
1513
|
+
"currentTime": datetime.utcnow().isoformat() + "Z",
|
|
1514
|
+
"interval": 300,
|
|
1515
|
+
"status": "Accepted",
|
|
1516
|
+
}
|
|
1517
|
+
elif action == "DataTransfer":
|
|
1518
|
+
reply_payload = await self._handle_data_transfer(msg_id, payload)
|
|
1519
|
+
elif action == "Heartbeat":
|
|
1520
|
+
reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
|
|
1521
|
+
now = timezone.now()
|
|
1522
|
+
self.charger.last_heartbeat = now
|
|
1523
|
+
if (
|
|
1524
|
+
self.aggregate_charger
|
|
1525
|
+
and self.aggregate_charger is not self.charger
|
|
1526
|
+
):
|
|
1527
|
+
self.aggregate_charger.last_heartbeat = now
|
|
1528
|
+
await database_sync_to_async(
|
|
1529
|
+
Charger.objects.filter(charger_id=self.charger_id).update
|
|
1530
|
+
)(last_heartbeat=now)
|
|
1531
|
+
elif action == "StatusNotification":
|
|
1532
|
+
await self._assign_connector(payload.get("connectorId"))
|
|
1533
|
+
status = (payload.get("status") or "").strip()
|
|
1534
|
+
error_code = (payload.get("errorCode") or "").strip()
|
|
1535
|
+
vendor_info = {
|
|
1536
|
+
key: value
|
|
1537
|
+
for key, value in (
|
|
1538
|
+
("info", payload.get("info")),
|
|
1539
|
+
("vendorId", payload.get("vendorId")),
|
|
1540
|
+
)
|
|
1541
|
+
if value
|
|
1542
|
+
}
|
|
1543
|
+
vendor_value = vendor_info or None
|
|
1544
|
+
timestamp_raw = payload.get("timestamp")
|
|
1545
|
+
status_timestamp = (
|
|
1546
|
+
parse_datetime(timestamp_raw) if timestamp_raw else None
|
|
1547
|
+
)
|
|
1548
|
+
if status_timestamp is None:
|
|
1549
|
+
status_timestamp = timezone.now()
|
|
1550
|
+
elif timezone.is_naive(status_timestamp):
|
|
1551
|
+
status_timestamp = timezone.make_aware(status_timestamp)
|
|
1552
|
+
update_kwargs = {
|
|
1553
|
+
"last_status": status,
|
|
1554
|
+
"last_error_code": error_code,
|
|
1555
|
+
"last_status_vendor_info": vendor_value,
|
|
1556
|
+
"last_status_timestamp": status_timestamp,
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
def _update_instance(instance: Charger | None) -> None:
|
|
1560
|
+
if not instance:
|
|
1561
|
+
return
|
|
1562
|
+
instance.last_status = status
|
|
1563
|
+
instance.last_error_code = error_code
|
|
1564
|
+
instance.last_status_vendor_info = vendor_value
|
|
1565
|
+
instance.last_status_timestamp = status_timestamp
|
|
1566
|
+
|
|
1567
|
+
await database_sync_to_async(
|
|
1568
|
+
Charger.objects.filter(
|
|
1569
|
+
charger_id=self.charger_id, connector_id=None
|
|
1570
|
+
).update
|
|
1571
|
+
)(**update_kwargs)
|
|
1572
|
+
connector_value = self.connector_value
|
|
1573
|
+
if connector_value is not None:
|
|
1574
|
+
await database_sync_to_async(
|
|
1575
|
+
Charger.objects.filter(
|
|
1576
|
+
charger_id=self.charger_id,
|
|
1577
|
+
connector_id=connector_value,
|
|
1578
|
+
).update
|
|
1579
|
+
)(**update_kwargs)
|
|
1580
|
+
_update_instance(self.aggregate_charger)
|
|
1581
|
+
_update_instance(self.charger)
|
|
1582
|
+
if connector_value is not None and status.lower() == "available":
|
|
1583
|
+
tx_obj = store.transactions.pop(self.store_key, None)
|
|
1584
|
+
if tx_obj:
|
|
1585
|
+
await self._cancel_consumption_message()
|
|
1586
|
+
store.end_session_log(self.store_key)
|
|
1587
|
+
store.stop_session_lock()
|
|
1588
|
+
store.add_log(
|
|
1589
|
+
self.store_key,
|
|
1590
|
+
f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
|
|
1591
|
+
log_type="charger",
|
|
1592
|
+
)
|
|
1593
|
+
availability_state = Charger.availability_state_from_status(status)
|
|
1594
|
+
if availability_state:
|
|
1595
|
+
await self._update_availability_state(
|
|
1596
|
+
availability_state, status_timestamp, self.connector_value
|
|
1597
|
+
)
|
|
1598
|
+
reply_payload = {}
|
|
1599
|
+
elif action == "Authorize":
|
|
1600
|
+
id_tag = payload.get("idTag")
|
|
1601
|
+
account = await self._get_account(id_tag)
|
|
1602
|
+
status = "Invalid"
|
|
1603
|
+
if self.charger.require_rfid:
|
|
1604
|
+
tag = None
|
|
1605
|
+
tag_created = False
|
|
1606
|
+
if id_tag:
|
|
1607
|
+
tag, tag_created = await database_sync_to_async(
|
|
1608
|
+
CoreRFID.register_scan
|
|
1609
|
+
)(id_tag)
|
|
1610
|
+
if account:
|
|
1611
|
+
if await database_sync_to_async(account.can_authorize)():
|
|
1612
|
+
status = "Accepted"
|
|
1613
|
+
elif (
|
|
1614
|
+
id_tag
|
|
1615
|
+
and tag
|
|
1616
|
+
and not tag_created
|
|
1617
|
+
and tag.allowed
|
|
1618
|
+
):
|
|
1619
|
+
status = "Accepted"
|
|
1620
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1621
|
+
else:
|
|
1622
|
+
await self._ensure_rfid_seen(id_tag)
|
|
1623
|
+
status = "Accepted"
|
|
1624
|
+
reply_payload = {"idTagInfo": {"status": status}}
|
|
1625
|
+
elif action == "MeterValues":
|
|
1626
|
+
await self._store_meter_values(payload, text_data)
|
|
1627
|
+
self.charger.last_meter_values = payload
|
|
1628
|
+
await database_sync_to_async(
|
|
1629
|
+
Charger.objects.filter(pk=self.charger.pk).update
|
|
1630
|
+
)(last_meter_values=payload)
|
|
1631
|
+
reply_payload = {}
|
|
1632
|
+
elif action == "DiagnosticsStatusNotification":
|
|
1633
|
+
status_value = payload.get("status")
|
|
1634
|
+
location_value = (
|
|
1635
|
+
payload.get("uploadLocation")
|
|
1636
|
+
or payload.get("location")
|
|
1637
|
+
or payload.get("uri")
|
|
1638
|
+
)
|
|
1639
|
+
timestamp_value = payload.get("timestamp")
|
|
1640
|
+
diagnostics_timestamp = None
|
|
1641
|
+
if timestamp_value:
|
|
1642
|
+
diagnostics_timestamp = parse_datetime(timestamp_value)
|
|
1643
|
+
if diagnostics_timestamp and timezone.is_naive(
|
|
1644
|
+
diagnostics_timestamp
|
|
1645
|
+
):
|
|
1646
|
+
diagnostics_timestamp = timezone.make_aware(
|
|
1647
|
+
diagnostics_timestamp, timezone=timezone.utc
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
updates = {
|
|
1651
|
+
"diagnostics_status": status_value or None,
|
|
1652
|
+
"diagnostics_timestamp": diagnostics_timestamp,
|
|
1653
|
+
"diagnostics_location": location_value or None,
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
def _persist_diagnostics():
|
|
1657
|
+
targets: list[Charger] = []
|
|
1658
|
+
if self.charger:
|
|
1659
|
+
targets.append(self.charger)
|
|
1660
|
+
aggregate = self.aggregate_charger
|
|
1661
|
+
if (
|
|
1662
|
+
aggregate
|
|
1663
|
+
and not any(
|
|
1664
|
+
target.pk == aggregate.pk for target in targets if target.pk
|
|
1665
|
+
)
|
|
1666
|
+
):
|
|
1667
|
+
targets.append(aggregate)
|
|
1668
|
+
for target in targets:
|
|
1669
|
+
for field, value in updates.items():
|
|
1670
|
+
setattr(target, field, value)
|
|
1671
|
+
if target.pk:
|
|
1672
|
+
Charger.objects.filter(pk=target.pk).update(**updates)
|
|
1673
|
+
|
|
1674
|
+
await database_sync_to_async(_persist_diagnostics)()
|
|
1675
|
+
|
|
1676
|
+
status_label = updates["diagnostics_status"] or "unknown"
|
|
1677
|
+
log_message = "DiagnosticsStatusNotification: status=%s" % (
|
|
1678
|
+
status_label,
|
|
1679
|
+
)
|
|
1680
|
+
if updates["diagnostics_timestamp"]:
|
|
1681
|
+
log_message += ", timestamp=%s" % (
|
|
1682
|
+
updates["diagnostics_timestamp"].isoformat()
|
|
1683
|
+
)
|
|
1684
|
+
if updates["diagnostics_location"]:
|
|
1685
|
+
log_message += ", location=%s" % updates["diagnostics_location"]
|
|
1686
|
+
store.add_log(self.store_key, log_message, log_type="charger")
|
|
1687
|
+
if self.aggregate_charger and self.aggregate_charger.connector_id is None:
|
|
1688
|
+
aggregate_key = store.identity_key(self.charger_id, None)
|
|
1689
|
+
if aggregate_key != self.store_key:
|
|
1690
|
+
store.add_log(aggregate_key, log_message, log_type="charger")
|
|
1691
|
+
reply_payload = {}
|
|
1692
|
+
elif action == "StartTransaction":
|
|
1693
|
+
id_tag = payload.get("idTag")
|
|
1694
|
+
tag = None
|
|
1695
|
+
tag_created = False
|
|
1696
|
+
if id_tag:
|
|
1697
|
+
tag, tag_created = await database_sync_to_async(
|
|
1698
|
+
CoreRFID.register_scan
|
|
1699
|
+
)(id_tag)
|
|
1700
|
+
account = await self._get_account(id_tag)
|
|
1701
|
+
if id_tag and not self.charger.require_rfid:
|
|
1702
|
+
seen_tag = await self._ensure_rfid_seen(id_tag)
|
|
1703
|
+
if seen_tag:
|
|
1704
|
+
tag = seen_tag
|
|
1705
|
+
await self._assign_connector(payload.get("connectorId"))
|
|
1706
|
+
authorized = True
|
|
1707
|
+
authorized_via_tag = False
|
|
1708
|
+
if self.charger.require_rfid:
|
|
1709
|
+
if account is not None:
|
|
1710
|
+
authorized = await database_sync_to_async(
|
|
1711
|
+
account.can_authorize
|
|
1712
|
+
)()
|
|
1713
|
+
elif (
|
|
1714
|
+
id_tag
|
|
1715
|
+
and tag
|
|
1716
|
+
and not tag_created
|
|
1717
|
+
and getattr(tag, "allowed", False)
|
|
1718
|
+
):
|
|
1719
|
+
authorized = True
|
|
1720
|
+
authorized_via_tag = True
|
|
1721
|
+
else:
|
|
1722
|
+
authorized = False
|
|
1723
|
+
if authorized:
|
|
1724
|
+
if authorized_via_tag and tag:
|
|
1725
|
+
self._log_unlinked_rfid(tag.rfid)
|
|
1726
|
+
start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1727
|
+
received_start = timezone.now()
|
|
1728
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1729
|
+
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1730
|
+
charger=self.charger,
|
|
1731
|
+
account=account,
|
|
1732
|
+
rfid=(id_tag or ""),
|
|
1733
|
+
vid=vid_value,
|
|
1734
|
+
vin=vin_value,
|
|
1735
|
+
connector_id=payload.get("connectorId"),
|
|
1736
|
+
meter_start=payload.get("meterStart"),
|
|
1737
|
+
start_time=start_timestamp or received_start,
|
|
1738
|
+
received_start_time=received_start,
|
|
1739
|
+
)
|
|
1740
|
+
store.transactions[self.store_key] = tx_obj
|
|
1741
|
+
store.start_session_log(self.store_key, tx_obj.pk)
|
|
1742
|
+
store.start_session_lock()
|
|
1743
|
+
store.add_session_message(self.store_key, text_data)
|
|
1744
|
+
await self._start_consumption_updates(tx_obj)
|
|
1745
|
+
reply_payload = {
|
|
1746
|
+
"transactionId": tx_obj.pk,
|
|
1747
|
+
"idTagInfo": {"status": "Accepted"},
|
|
1748
|
+
}
|
|
1749
|
+
else:
|
|
1750
|
+
reply_payload = {"idTagInfo": {"status": "Invalid"}}
|
|
1751
|
+
elif action == "StopTransaction":
|
|
1752
|
+
tx_id = payload.get("transactionId")
|
|
1753
|
+
tx_obj = store.transactions.pop(self.store_key, None)
|
|
1754
|
+
if not tx_obj and tx_id is not None:
|
|
1755
|
+
tx_obj = await database_sync_to_async(
|
|
1756
|
+
Transaction.objects.filter(pk=tx_id, charger=self.charger).first
|
|
1757
|
+
)()
|
|
1758
|
+
if not tx_obj and tx_id is not None:
|
|
1759
|
+
received_start = timezone.now()
|
|
1760
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1761
|
+
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1762
|
+
pk=tx_id,
|
|
1763
|
+
charger=self.charger,
|
|
1764
|
+
start_time=received_start,
|
|
1765
|
+
received_start_time=received_start,
|
|
1766
|
+
meter_start=payload.get("meterStart")
|
|
1767
|
+
or payload.get("meterStop"),
|
|
1768
|
+
vid=vid_value,
|
|
1769
|
+
vin=vin_value,
|
|
1770
|
+
)
|
|
1771
|
+
if tx_obj:
|
|
1772
|
+
stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1773
|
+
received_stop = timezone.now()
|
|
1774
|
+
tx_obj.meter_stop = payload.get("meterStop")
|
|
1775
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1776
|
+
if vid_value:
|
|
1777
|
+
tx_obj.vid = vid_value
|
|
1778
|
+
if vin_value:
|
|
1779
|
+
tx_obj.vin = vin_value
|
|
1780
|
+
tx_obj.stop_time = stop_timestamp or received_stop
|
|
1781
|
+
tx_obj.received_stop_time = received_stop
|
|
1782
|
+
await database_sync_to_async(tx_obj.save)()
|
|
1783
|
+
await self._update_consumption_message(tx_obj.pk)
|
|
1784
|
+
await self._cancel_consumption_message()
|
|
1785
|
+
reply_payload = {"idTagInfo": {"status": "Accepted"}}
|
|
1786
|
+
store.end_session_log(self.store_key)
|
|
1787
|
+
store.stop_session_lock()
|
|
1788
|
+
elif action == "FirmwareStatusNotification":
|
|
1789
|
+
status_raw = payload.get("status")
|
|
1790
|
+
status = str(status_raw or "").strip()
|
|
1791
|
+
info_value = payload.get("statusInfo")
|
|
1792
|
+
if not isinstance(info_value, str):
|
|
1793
|
+
info_value = payload.get("info")
|
|
1794
|
+
status_info = str(info_value or "").strip()
|
|
1795
|
+
timestamp_raw = payload.get("timestamp")
|
|
1796
|
+
timestamp_value = None
|
|
1797
|
+
if timestamp_raw:
|
|
1798
|
+
timestamp_value = parse_datetime(str(timestamp_raw))
|
|
1799
|
+
if timestamp_value and timezone.is_naive(timestamp_value):
|
|
1800
|
+
timestamp_value = timezone.make_aware(
|
|
1801
|
+
timestamp_value, timezone.get_current_timezone()
|
|
1802
|
+
)
|
|
1803
|
+
if timestamp_value is None:
|
|
1804
|
+
timestamp_value = timezone.now()
|
|
1805
|
+
await self._update_firmware_state(
|
|
1806
|
+
status, status_info, timestamp_value
|
|
1807
|
+
)
|
|
1808
|
+
store.add_log(
|
|
1809
|
+
self.store_key,
|
|
1810
|
+
"FirmwareStatusNotification: "
|
|
1811
|
+
+ json.dumps(payload, separators=(",", ":")),
|
|
1812
|
+
log_type="charger",
|
|
1813
|
+
)
|
|
1814
|
+
if (
|
|
1815
|
+
self.aggregate_charger
|
|
1816
|
+
and self.aggregate_charger.connector_id is None
|
|
1817
|
+
):
|
|
1818
|
+
aggregate_key = store.identity_key(
|
|
1819
|
+
self.charger_id, self.aggregate_charger.connector_id
|
|
1820
|
+
)
|
|
1821
|
+
if aggregate_key != self.store_key:
|
|
1822
|
+
store.add_log(
|
|
1823
|
+
aggregate_key,
|
|
1824
|
+
"FirmwareStatusNotification: "
|
|
1825
|
+
+ json.dumps(payload, separators=(",", ":")),
|
|
1826
|
+
log_type="charger",
|
|
1827
|
+
)
|
|
1828
|
+
reply_payload = {}
|
|
1829
|
+
response = [3, msg_id, reply_payload]
|
|
1830
|
+
await self.send(json.dumps(response))
|
|
1831
|
+
store.add_log(
|
|
1832
|
+
self.store_key, f"< {json.dumps(response)}", log_type="charger"
|
|
1833
|
+
)
|
|
1834
|
+
elif message_type == 3:
|
|
1835
|
+
msg_id = msg[1] if len(msg) > 1 else ""
|
|
1836
|
+
payload = msg[2] if len(msg) > 2 else {}
|
|
1837
|
+
await self._handle_call_result(msg_id, payload)
|
|
1838
|
+
elif message_type == 4:
|
|
1839
|
+
msg_id = msg[1] if len(msg) > 1 else ""
|
|
1840
|
+
error_code = msg[2] if len(msg) > 2 else ""
|
|
1841
|
+
description = msg[3] if len(msg) > 3 else ""
|
|
1842
|
+
details = msg[4] if len(msg) > 4 else {}
|
|
1843
|
+
await self._handle_call_error(msg_id, error_code, description, details)
|